Skip to content

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

Description

@aiwantaozi

Background

truststore on Linux currently calls ctx.set_default_verify_paths() on every
wrap_socket() call (see _openssl.py:_configure_context, added in v0.10.0).

OpenSSL versions before 3.4 do not deduplicate the default CApath when
set_default_verify_paths() is called repeatedly, so each call appends a new
X509_LOOKUP_hash_dir entry to the X509_STORE. The verify path then iterates
all entries, leading to O(N²) opendir(capath) calls over the lifetime of a
shared SSLContext.

The duplicate-add behavior in OpenSSL was fixed in openssl/openssl#24140
("Avoid Duplicate Dir Search", merged April 2024, released in OpenSSL 3.4.0):
openssl/openssl#24140

But for users on:

  • Debian 12 (OpenSSL 3.0.x)
  • Ubuntu 22.04 LTS (OpenSSL 3.0.2)
  • Ubuntu 24.04 LTS (OpenSSL 3.0.x)
  • RHEL 9 (OpenSSL 3.0.x)

...the bug is unfixed at the OpenSSL layer, and truststore's pattern is the
amplifier. This affects every long-running Python service that uses truststore
on these distros.

The underlying performance regression was also independently noticed in
python/cpython#95031 (still open since 2022), though that issue stopped at
"5x slower" without identifying the accumulation mechanism.

Why fix in truststore even though OpenSSL 3.4+ has dedup

  1. All current stable LTS distros ship OpenSSL 3.0.x.
    These won't get 3.4+ until their next major release (e.g., Debian 13).
  2. Even on OpenSSL 3.4+, repeatedly calling set_default_verify_paths() on
    the same SSLContext is wasted work.
  3. The fix in truststore is small: cache "we already set default verify paths
    on this SSLContext" and skip on subsequent calls.

Reproduction

Image 1:debian:bookworm(OpenSSL 3.0.20,reproduced)

docker run --rm \
  --cap-add=SYS_PTRACE \
  --security-opt seccomp=unconfined \
  -v $PWD/truststore_repro.sh:/repro.sh \
  debian:bookworm bash -c '
  apt-get update -qq
  apt-get install -qq -y python3 python3-pip strace openssl ca-certificates >/dev/null 2>&1
  pip3 install --break-system-packages -q truststore 2>&1 | tail -1
  bash /repro.sh
'

output:

== Environment ==
  python      = 3.11.2
  openssl     = OpenSSL 3.0.20 ...
  truststore  = 0.10.4
  cafile      = /usr/lib/ssl/cert.pem (exists=True)
  capath      = /usr/lib/ssl/certs (exists=True)
  capath has 285 hash entries

== strace sanity check ==
  strace works ✓

== Quadratic growth in opendir(capath) ==
  n=  5  opendir=    35  opendir/n=   7.0
  n= 20  opendir=   440  opendir/n=  22.0
  n= 50  opendir=  2600  opendir/n=  52.0
  n=200  opendir= 40400  opendir/n= 202.0

Image 2:python:3.11-slim(OpenSSL 3.5.6,Can't reproduce / upstream already fixed)

docker run --rm \
  --cap-add=SYS_PTRACE \
  --security-opt seccomp=unconfined \
  -v $PWD/truststore_repro.sh:/repro.sh \
  python:3.11-slim bash -c '
  apt-get update -qq && apt-get install -qq -y strace openssl >/dev/null 2>&1
  pip install -q truststore
  bash /repro.sh
'

output:

== Environment ==
  python      = 3.11.15
  openssl     = OpenSSL 3.5.6 ...
  capath      = /usr/lib/ssl/certs (exists=True)
  capath has 301 hash entries

== Quadratic growth in opendir(capath) ==
  n=  5  opendir=     0  opendir/n=   0.0
  n= 20  opendir=     0  opendir/n=   0.0
  n= 50  opendir=     0  opendir/n=   0.0
  n=200  opendir=     0  opendir/n=   0.0

truststore_repro.sh

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions