Skip to content

Cache automatic OpenSSL verify path setup - #213

Open
Cr1stal wants to merge 2 commits into
sethmlarson:mainfrom
Cr1stal:feature/cache-openssl-verify-paths
Open

Cr1stal wants to merge 2 commits into
sethmlarson:mainfrom
Cr1stal:feature/cache-openssl-verify-paths

Conversation

@Cr1stal

@Cr1stal Cr1stal commented Jun 1, 2026

Copy link
Copy Markdown

Summary

  • cache truststore's automatic OpenSSL verify-path setup per internal SSLContext
  • keep explicit user calls to set_default_verify_paths() user-controlled and unchanged
  • add OpenSSL backend unit tests for default paths, capath, fallback cafile, and no-location caching

Why

On OpenSSL 3.0-3.3, repeated automatic calls to set_default_verify_paths() on the same SSLContext can append duplicate CApath lookups. Long-lived clients that reuse one context can then hit quadratic opendir(capath) growth during certificate verification.

This change only deduplicates truststore's automatic pre-wrap configuration. If a caller explicitly invokes SSLContext.set_default_verify_paths(), truststore still passes that through directly.

Fixes #212.

Validation

  • PRE_COMMIT_HOME=/private/tmp/truststore-pre-commit-cache /private/tmp/truststore-pr-venv/bin/pre-commit run --all-files
  • CI=1 /private/tmp/truststore-pr-venv/bin/nox -s test -- tests/test_openssl.py tests/test_threading.py
    • tests/test_threading.py::test_threading skipped locally because mkcert is not installed
  • CI=1 /private/tmp/truststore-pr-venv/bin/nox -s test -- tests/test_api.py::test_trustme_cert tests/test_api.py::test_trustme_cert_loaded_via_capath
  • python3 -m compileall -qq -f src/truststore/ tests/test_openssl.py
  • Docker remote repro on Debian bookworm / OpenSSL 3.0.20:
handshakes old capath opens fixed capath opens
5 35 11
20 440 41
50 2600 101
100 10200 201
200 40400 401

@Cr1stal
Cr1stal marked this pull request as ready for review June 1, 2026 07:42
@Cr1stal

Cr1stal commented Jun 1, 2026

Copy link
Copy Markdown
Author

@sethmlarson, could you look into this, please?

@jbeckwith-oai

Copy link
Copy Markdown

Hi! Thanks for working on this. We've been chasing openai/openai-python#3777, and I wanted to bring back some concrete results: we tested this exact PR at 058a811b8bbef1fdd7dd65582276c408f5ede276, and it fixes our repro in both async transports.

We ran the PR source unchanged on Ubuntu 24.04, Python 3.12.3, and OpenSSL 3.0.13, with OpenAI 3.6.0, HTTPX2/httpcore2 2.12.0, anyio 4.14.2, and aiohttp 3.14.3. No monkeypatch or SSL-context workaround in these runs.

Our local server sends a leaf and intermediate certificate, and the client trusts the root. One reused client makes 500 requests, all returning HTTP 200 with certificate and hostname verification enabled. The server closes each connection so we can exercise fresh handshakes quickly. Retries are off.

Transport Released 0.10.4, CPU/request This PR, CPU/request
HTTPX2 36.58 → 59.14 ms 4.05 → 4.08 ms
SDK's aiohttp adapter 34.93 → 57.13 ms 3.70 → 3.41 ms

The arrows compare requests 101-200 with 401-500. Runs were sequential, and CPU includes the local server on the same event loop. Task counts stayed flat.

The directory scans tell the same story. With 0.10.4, 50 successful requests caused 1,275 opens of /usr/lib/ssl/certs. With this PR, 5, 20, and 50 requests caused exactly 5, 20, and 50 opens.

We also checked that verification still does its job:

  • Both SDK transports rejected all five requests with an untrusted certificate chain. We checked for SSLCertVerificationError in the exception chain, rather than accepting any connection error.
  • 10 focused upstream tests passed, none skipped: the four new OpenSSL setup tests, existing urllib3/aiohttp/requests custom-CA tests, the shared-context threading test, and the cafile/capath certificate-loading tests. The flaky-marked tests passed on their first attempts.

One regression case I'd suggest keeping is the valid chain with an intermediate supplied by the server. Our simpler leaf-to-root fixture missed the bug entirely. This matters for ordinary long-lived services: one of the SDK reports involves little more than a health check every 15 seconds.

This is Linux validation of the submitted head, not a full cross-platform review. We haven't tested certificate-store changes during process lifetime or rebased the PR onto newer main.

Anything else we can help test to get this over the line? We'd really like to give affected users a fixed dependency they can upgrade to. Appreciate the work here!

Full reproducer and the HTTPX2 tracking issue: pydantic/httpx2#1252.

@notatallshaw

Copy link
Copy Markdown
Collaborator

I had Daybreak Blue do quick review and it thinks it's found two medium regressions. Listed with MREs in expanded detail sections:


A reused context no longer sees a CA added to its default bundle. Main accepts the next request; this PR keeps rejecting it until set_default_verify_paths() is called explicitly.

CA-bundle refresh reproducer
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "trustme==1.2.1",
#     "truststore @ git+https://github.com/sethmlarson/truststore.git@bd68d581c011d406daf5ddec7e21e38794772c47",
# ]
# ///
import http.client
import http.server
import os
import ssl
import tempfile
import threading
from pathlib import Path

import trustme
import truststore


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()

    def log_message(self, *_args):
        pass


def request():
    connection = http.client.HTTPSConnection(
        "localhost",
        server.server_port,
        context=client,
        timeout=5,
    )
    try:
        connection.request("GET", "/")
        return f"HTTP {connection.getresponse().status}"
    except ssl.SSLCertVerificationError:
        return "certificate rejected"
    finally:
        connection.close()


with tempfile.TemporaryDirectory() as directory:
    bundle = Path(directory) / "roots.pem"
    bundle.write_bytes(trustme.CA().cert_pem.bytes())
    os.environ.update(SSL_CERT_FILE=str(bundle), SSL_CERT_DIR=directory)
    ca = trustme.CA()
    server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ca.issue_cert("localhost").configure_cert(server_context)
    client = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)

    with http.server.HTTPServer(("127.0.0.1", 0), Handler) as server:
        server.socket = server_context.wrap_socket(server.socket, server_side=True)
        threading.Thread(target=server.serve_forever, daemon=True).start()
        try:
            print("Before update:", request())
            with bundle.open("ab") as stream:
                stream.write(ca.cert_pem.bytes())
            print("After update:", request())
            client.set_default_verify_paths()
            print("After explicit reload:", request())
        finally:
            server.shutdown()
$ uv run mre_bundle_refresh.py  # PR revision pinned above
Before update: certificate rejected
After update: certificate rejected
After explicit reload: HTTP 200

$ uv run --with 'truststore @ git+https://github.com/sethmlarson/truststore.git@98195f0d27c3594899feb8d04a47f081959c6ba5' mre_bundle_refresh.py
Before update: certificate rejected
After update: HTTP 200
After explicit reload: HTTP 200

The new module-level Lock makes setup non-reentrant. A synchronous, guarded audit hook can initialize another TLS context while _configure_context() is handling the documented os.listdir event. Main completes; this PR deadlocks on _CONFIGURE_LOCK. Does not require fork or thread race to trigger.

Reentrant setup reproducer
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "truststore @ git+https://github.com/sethmlarson/truststore.git@bd68d581c011d406daf5ddec7e21e38794772c47",
# ]
# ///
import os
import signal
import ssl
import sys
import tempfile
from pathlib import Path

import truststore

with tempfile.TemporaryDirectory() as ca_directory:
    os.environ["SSL_CERT_FILE"] = str(Path(ca_directory) / "missing.pem")
    os.environ["SSL_CERT_DIR"] = ca_directory
    handled = []

    def audit(event, args):
        if event == "os.listdir" and args[0] == ca_directory and not handled:
            handled.append(True)
            print("Audit callback entered", flush=True)
            nested = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
            nested.wrap_bio(
                ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="localhost"
            )

    sys.addaudithook(audit)
    signal.alarm(3)
    context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="localhost")
    signal.alarm(0)
    print("TLS setup completed after the audit callback")

The PR prints Audit callback entered and is killed by the three-second alarm. Overriding truststore with 98195f0 prints:

Audit callback entered
TLS setup completed after the audit callback

@sethmlarson

Copy link
Copy Markdown
Owner

For those interested, I created an alternate implementation using a different mechanism to detect when to reload certificates: #244

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

set_default_verify_paths() called per wrap_socket() causes O(N²) opendir storm on OpenSSL 3.0–3.3

4 participants