Skip to content

Add opt-in TCPConnector(use_truststore=True) for OS-native trust store integration (#11705)#12702

Open
Krishnachaitanyakc wants to merge 15 commits into
aio-libs:masterfrom
Krishnachaitanyakc:truststore-opt-in-11705
Open

Add opt-in TCPConnector(use_truststore=True) for OS-native trust store integration (#11705)#12702
Krishnachaitanyakc wants to merge 15 commits into
aio-libs:masterfrom
Krishnachaitanyakc:truststore-opt-in-11705

Conversation

@Krishnachaitanyakc

Copy link
Copy Markdown
Contributor

Closes

Closes #11705 (in part — this is the additive, opt-in step that the issue's action list anticipates as "follow pip's example and make it optional first").

What this changes

Adds a new keyword-only use_truststore: bool = False parameter to TCPConnector. When set to True, certificate verification is delegated to the OS-native trust store via the truststore library, fixing CERTIFICATE_VERIFY_FAILED errors for users behind enterprise TLS-intercepting proxies whose root CA is installed in the macOS Keychain or Windows certificate stores but not in OpenSSL's default paths.

  • New dedicated optional extra: pip install aiohttp[truststore] (kept separate from speedups because trust-store integration is a correctness/functionality concern, not a perf accelerator).
  • use_truststore=True without the dependency installed raises a friendly RuntimeError at TCPConnector construction, not deep in an async handshake stack.
  • use_truststore=True together with ssl=False raises ValueError — there is no concept of an "unverified truststore" context.
  • An explicit ssl=<SSLContext> always wins over use_truststore=True.
  • Default behaviour is unchanged. Existing users who pass ssl=, ssl_context=, or rely on defaults see zero behaviour change.
  • truststore is an OPTIONAL dependency. Without it installed, aiohttp works exactly as today.
  • Per-connector context instance, not a module-level singleton — preserves zero-import-cost for users who do not opt in.

Why this is "step 1"

The issue body explicitly leaves the rollout strategy open:

truststore should probably be a mandatory runtime dependency in packaging core metadata; although, maybe we need to follow pip's example and make it optional first (via extras or manual install) and then add it unconditionally later.

This PR is the "optional first" step. A follow-up PR can flip the default and add truststore as a hard dependency once the opt-in path has bake-in time, and that follow-up will also expand THREAT_MODEL.md to cover the new default trust source. Keeping the steps separate makes the design conversation per PR small and reviewable.

Documentation

  • docs/client_advanced.rst: fixed the misleading "By default, Python uses the system CA certificates." sentence (which is wrong on macOS, partially wrong on Windows) and added a new "Example: Use truststore for system trust stores" section explaining the corporate-proxy use case.
  • docs/client_reference.rst: added use_truststore to the TCPConnector signature block and a parameter description with .. versionadded:: 3.14.

Tests

12 new tests in tests/test_truststore.py covering all branches: default off, off-no-import, on-with-truststore, on-without-truststore, ssl=False guard, explicit-context precedence, per-instance isolation, dispatch return value, fingerprint composition, unverified-path ignores flag, ALPN compatibility, and the import helper itself. Truststore-dependent tests use pytest.importorskip so the suite still runs without the optional dependency.

Local test results: 12 passed, 0 failed. Full tests/test_connector.py regression run: 179 passed, 7 skipped, 1 xfailed — no regression.

Manual reproduction

End-to-end smoke test (trustme-issued self-signed cert)

TCPConnector(use_truststore=True) constructs a truststore.SSLContext instance. A separate HTTPS round trip against a self-signed trustme-issued cert confirms the wider SSL pipeline still works under truststore monkey-patching. Output:

OK: TCPConnector(use_truststore=True) built a truststore.SSLContext
OK: HTTPS round trip succeeded: status=200 body='ok-truststore'

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • Added a CHANGES file (CHANGES/11705.feature.rst)
  • Added myself to CONTRIBUTORS.txt
  • Followed AGENTS.md branching and draft-PR rules

Credit

Thank you @sethmlarson for the truststore library, and @webknjaz for the structured tracking issue and the open call for new volunteers.

aio-libs#11705)

Add a keyword-only ``use_truststore: bool = False`` parameter to
``TCPConnector``. When set to ``True``, certificate verification is
delegated to the OS-native trust store via the ``truststore`` library,
fixing ``CERTIFICATE_VERIFY_FAILED`` errors for users behind enterprise
TLS-intercepting proxies whose root CA is installed in the macOS Keychain
or Windows certificate stores but not in OpenSSL's default paths.

- New dedicated optional extra: ``pip install aiohttp[truststore]``
- ``use_truststore=True`` without the dependency installed raises a
  friendly ``RuntimeError`` at construction time
- ``use_truststore=True`` together with ``ssl=False`` raises ``ValueError``
- Explicit ``ssl=<SSLContext>`` always wins over ``use_truststore=True``
- Default behaviour is unchanged
- Per-connector context instance, not a module-level singleton

Also fixes a misleading sentence in ``docs/client_advanced.rst`` that
claimed Python uses the system CA certificates by default (untrue on
macOS, partially wrong on Windows).

This is the "optional first" step from @webknjaz's structured plan in
the issue; a follow-up PR can flip the default and add truststore as a
hard dependency once the opt-in path has bake-in time.

Tests: 12 new tests in ``tests/test_truststore.py`` covering all
branches. Full ``tests/test_connector.py`` regression run: 179 passed,
7 skipped, 1 xfailed (no regression).
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label May 26, 2026
@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.03960% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.66%. Comparing base (534a758) to head (1619c1e).
⚠️ Report is 7 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
aiohttp/connector.py 88.88% 2 Missing ⚠️
tests/test_truststore.py 97.59% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #12702      +/-   ##
==========================================
- Coverage   98.89%   98.66%   -0.24%     
==========================================
  Files         131      130       -1     
  Lines       46798    46986     +188     
  Branches     2426     2426              
==========================================
+ Hits        46283    46357      +74     
- Misses        387      488     +101     
- Partials      128      141      +13     
Flag Coverage Δ
Autobahn ?
CI-GHA 98.23% <96.03%> (-0.65%) ⬇️
OS-Linux 97.60% <96.03%> (-1.03%) ⬇️
OS-Windows 97.02% <96.03%> (+0.11%) ⬆️
OS-macOS 97.57% <96.03%> (-0.33%) ⬇️
Py-3.10 96.28% <96.03%> (-1.84%) ⬇️
Py-3.11 97.66% <96.03%> (-0.72%) ⬇️
Py-3.12 96.62% <96.03%> (-1.85%) ⬇️
Py-3.13 97.51% <96.03%> (-0.08%) ⬇️
Py-3.14 97.79% <96.03%> (+0.18%) ⬆️
Py-3.14t 97.53% <96.03%> (+<0.01%) ⬆️
VM-macos 97.57% <96.03%> (-0.33%) ⬇️
VM-ubuntu 97.60% <96.03%> (-1.03%) ⬇️
VM-windows 97.02% <96.03%> (+0.11%) ⬆️
cython-coverage 37.86% <5.94%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented May 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 72 untouched benchmarks
⏩ 72 skipped benchmarks1


Comparing Krishnachaitanyakc:truststore-opt-in-11705 (1619c1e) with master (af3e950)

Open in CodSpeed

Footnotes

  1. 72 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Krishnachaitanyakc and others added 3 commits May 26, 2026 08:50
…1705)

Three classes of CI failure from prior commit:

1. `mypy` (Linter job) — `Cannot find implementation or library stub for
   module named "truststore"`. CI does not install the optional dep, so
   `[mypy-truststore] ignore_missing_imports` alone is insufficient
   (only takes effect when mypy CAN find the module). Add per-import
   `# type: ignore[import-not-found,unused-ignore]` so the type-check
   passes whether or not the dep is present. Keep the .mypy.ini section
   for the case when the dep IS installed locally and somebody runs
   mypy with full env.

2. `flake8-requirements` (I900) — `'truststore' not listed as a
   requirement`. Same root cause: the optional dep is not in the
   default test requirements. Add `# noqa: I900` on each truststore
   import. Also add `truststore` (gated on Python >=3.10 cpython) to
   `requirements/test-common.in` so the test environment installs it
   and the skipif-gated tests actually run -- this also addresses the
   codecov patch-coverage drop from the prior commit (every truststore
   test was being skipped because the dep was missing).

3. `flake8-docstrings` D205/D209 — multi-line docstrings without a
   blank line between summary and description and with closing quotes
   on the body line. Collapse the affected docstrings to single-line
   summaries; preserve multi-line form only where there is genuine
   prose after the summary (and add the blank line / standalone closing
   quotes where preserved).

Verified locally with truststore installed and uninstalled: 0 errors
on `aiohttp/connector.py` and `tests/test_truststore.py` under
`mypy aiohttp/connector.py tests/test_truststore.py` and `flake8 -j 1
aiohttp/connector.py tests/test_truststore.py`.

Note: requirements/*.txt lock files are not regenerated in this commit
because pip-compile output depends on the resolving Python version and
produces off-target diffs; the maintainer can regenerate via the
project's standard workflow.

Refs aio-libs#11705
@Krishnachaitanyakc Krishnachaitanyakc marked this pull request as ready for review May 26, 2026 21:56
Comment thread aiohttp/connector.py Outdated
@Dreamsorcerer Dreamsorcerer added the pr-unfinished The PR is unfinished and may need a volunteer to complete it label May 30, 2026
…bs#11705)

Address review feedback on aio-libs#12702. Replace the opt-in
`TCPConnector(use_truststore=True)` parameter with the shape requested in
the issue and by maintainers: prefer `truststore` automatically when the
optional library is importable, fall back to stdlib `ssl` otherwise. Model
the shielded module-level import on the existing brotli pattern in
`aiohttp/compression_utils.py`.

- `aiohttp/connector.py`:
  - Shielded `try: import truststore; HAS_TRUSTSTORE = True except
    ImportError: HAS_TRUSTSTORE = False` at module top.
  - `_make_ssl_context(verified)` prefers `truststore.SSLContext` when
    `HAS_TRUSTSTORE` and `verified`; falls back to
    `ssl.create_default_context()`. `set_alpn_protocols` runs in both
    branches.
  - Remove `use_truststore` kwarg, `_use_truststore` /
    `_ssl_context_truststore` instance state, the `ValueError` for
    `ssl=False`, and the truststore branch in `_get_ssl_context`. The
    module-level `_SSL_CONTEXT_VERIFIED` now carries the auto-prefer
    behaviour.
  - Remove the `_import_truststore()` helper and its RuntimeError path.
- `tests/test_truststore.py`: rewrite around `HAS_TRUSTSTORE` with both
  branches covered (installed via `pytest.importorskip` / skipif; absent
  via `mock.patch.object(connector_module, "HAS_TRUSTSTORE", False)`). The
  fallback assertion uses `type(ctx) is ssl.SSLContext` because
  `truststore.SSLContext` subclasses `ssl.SSLContext` and `isinstance`
  would pass in both branches.
- `docs/client_reference.rst`: drop `use_truststore=False` from the
  constructor signature and the `:param`. Add a `versionchanged:: 3.14`
  note describing the auto-prefer.
- `docs/client_advanced.rst`: rewrite the example -- `pip install
  aiohttp[truststore]` is all that is required; show the explicit-context
  escape hatch for users who want to force the stdlib trust paths.
- `CHANGES/11705.feature.rst`: rewrite the fragment to describe the
  automatic preference rather than an opt-in flag.

`truststore` remains an optional extra in `pyproject.toml`; it is not
promoted to a required runtime dependency. `THREAT_MODEL.md` was inspected
and does not reference the default TLS trust source, so no chunk update is
included.

Tests: 8 in `tests/test_truststore.py` (all pass); regression run on
`tests/test_connector.py` reports `179 passed, 7 skipped, 1 xfailed`,
matching the baseline.
@github-actions github-actions Bot removed the pr-unfinished The PR is unfinished and may need a volunteer to complete it label May 30, 2026
Comment thread aiohttp/connector.py Outdated
Krishnachaitanyakc and others added 5 commits May 31, 2026 10:22
Truststore's _configure_context probes well-known CA file/directory
locations on every wrap_socket call. On Linux the openssl backend calls
os.path.isfile / os.path.isdir / os.listdir, which blockbuster catches
as blocking I/O on the event loop. Add a narrow allowance scoped to
truststore/_openssl.py frames so the existing test suite passes when
truststore is installed.

Also add MDM and stdlib to the spelling wordlist for new prose in the
truststore docs section.
New prose in the truststore docs sections introduces these tokens
in bare text (outside any role); add them to docs/spelling_wordlist.txt
so the docs spelling check passes.
"importable" is not in enchant's en_US dictionary; appears in the new
truststore docs prose ("whenever the library is importable").
Comment thread aiohttp/connector.py Outdated
Dreamsorcerer and others added 2 commits May 31, 2026 20:26
…11705)

truststore's intersphinx inventory does not expose SSLContext as a
py:class target, so the :class:\`truststore.SSLContext\` references fail
under -W with "py:class reference target not found". Prefix the cross-
reference name with "!" to keep the inline styling without attempting
to resolve the target.
Comment thread docs/client_advanced.rst Outdated
Comment thread tests/conftest.py Outdated
Comment on lines +107 to +111
for func, fn_names in (
("os.stat", ("_configure_context", "_capath_contains_certs")),
("os.listdir", ("_capath_contains_certs",)),
):
bb.functions[func].can_block_in("truststore/_openssl.py", fn_names)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like blockbuster is hitting in the _create_connection_transport chain and this papering over the issue. This is a blocking os.stat and an os.listdir in the call chain

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a limit of Python's ssl module, or something truststore could improve in some way?

@bdraco bdraco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#12702 (comment) needs to be fixed before merging especially since this automatically loads when its installed and is not truly opt-in. The import time SSLContext that is currently used is designed to avoid the blocking I/O at runtime, and this PR regresses that.

Krishnachaitanyakc and others added 2 commits May 31, 2026 15:48
The previous iteration auto-preferred truststore whenever importable,
which made the module-level _SSL_CONTEXT_VERIFIED a truststore.SSLContext
and reintroduced per-handshake blocking I/O on the event loop
(_configure_context runs from wrap_socket on every connection and probes
os.path.isfile/isdir and os.listdir). bdraco flagged that the conftest
blockbuster allowance was papering over a real regression of the
existing import-time SSLContext design contract.

Restore the original opt-in semantics:

- _make_ssl_context grows a use_truststore keyword; the truststore
  branch only fires when explicitly requested. Missing dependency now
  raises RuntimeError at the opt-in site instead of silently falling
  back.
- Module-level _SSL_CONTEXT_VERIFIED stays plain ssl.SSLContext, built
  once at import — default request path performs zero per-handshake
  file I/O, matching pre-PR behaviour.
- TCPConnector grows use_truststore=False; when True, the truststore
  context is built eagerly in __init__ so dependency errors surface
  at construction. ssl=False + use_truststore=True raises ValueError.
- _get_ssl_context prefers the per-instance truststore context when
  present, falls back to the module-level stdlib context otherwise.
- Remove the conftest blockbuster allowance: the default path no longer
  exercises truststore code, and opt-in tests carry skip_blockbuster
  because the user has explicitly accepted that cost.
- Rewrite tests/test_truststore.py around the opt-in flag with a
  regression guard that _SSL_CONTEXT_VERIFIED is type(ctx) is
  ssl.SSLContext (not just isinstance — truststore subclasses).
- Update docs to document use_truststore as an opt-in parameter and
  call out the per-handshake I/O cost so users opt in knowingly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[TODO] Use truststore in place of ssl by default

3 participants