I was scrolling through bug reports a while back and one of them made me stop: a Python tool that dies on import with ssl.SSLError: [ASN1] nested asn1 error. Machine was fine, network was fine, code was fine...... the user had just upgraded the app. Rolling back fixed it, which is the sort of detail that sends you hunting in the wrong place for an hour.
I dug into it, and as far as I can tell the app was never the problem. One certificate sitting in the Windows certificate store was.
What is actually blowing up
On Windows, CPython's ssl module does not just read a bundled CA file. When something asks for a default SSL context, it walks the Windows certificate stores and collects the certs into one big buffer. Here is the code from Lib/ssl.py:
def _load_windows_store_certs(self, storename, purpose):
certs = bytearray()
try:
for cert, encoding, trust in enum_certificates(storename):
# CA certs are never PKCS#7 encoded
if encoding == "x509_asn":
if trust is True or purpose.oid in trust:
certs.extend(cert)
except PermissionError:
warnings.warn("unable to enumerate Windows certificate store")
if certs:
self.load_verify_locations(cadata=certs)
return certs
Read that last part again. Every cert goes into certs, and then the whole pile is handed to load_verify_locations in a single call. The stores it walks are ('CA', 'ROOT').
So if one cert in that pile is malformed, the DER parse fails for the entire buffer:
ssl.SSLError: [ASN1] nested asn1 error
The bad one might be the 45th out of 45. The rest are perfectly good. Doesn't matter. The call throws, the context never gets built, and whichever import or TLS call comes first is the one that dies. Your code never gets a say in it.
Depending on how the bytes are mangled you may get a different message for the same underlying problem: SSLError(0, 'not enough data: cadata does not contain a certificate'). I hit both while testing.
I reproduced it in about 20 lines
I didn't want to take this one on faith, so I ran it. Enumerating the ROOT store on my Windows 11 box gave 45 certs, 48,107 bytes of DER. Loaded whole, everything is happy. Then I flipped a single byte inside one certificate and loaded the exact same buffer:
python: 3.11.15 | openssl: OpenSSL 3.5.6
ROOT store: 45 certs, 48107 bytes DER
baseline load_verify_locations(all store certs) -> OK
REPRO: 1 corrupted cert among 45 valid -> SSLError: [ASN1] nested asn1 error (_ssl.c:4057)
One byte. That is the whole bug. If you want to poke at it on your own machine:
import ssl
from _ssl import enum_certificates
certs = [c for c, enc, trust in enum_certificates("ROOT") if enc == "x509_asn"]
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_verify_locations(cadata=b"".join(certs)) # works ... unless one cert is bad
Why SSL_CERT_FILE does not save you here
The instinct is to point Python at a known-good bundle and move on. On Windows that does not stop the scan, because of the order these things run in. SSLContext.load_default_certs walks the stores first and only afterwards calls set_default_verify_paths(), which is where SSL_CERT_FILE and SSL_CERT_DIR are actually consulted:
def load_default_certs(self, purpose=Purpose.SERVER_AUTH):
...
if sys.platform == "win32":
for storename in self._windows_cert_stores:
self._load_windows_store_certs(storename, purpose)
self.set_default_verify_paths()
The bad cert blows up the buffer before your environment variable is ever read. Same story for any tool that builds its context with a bare ssl.create_default_context().
I'm reading this off CPython 3.11 and 3.12. If you're on something newer, .../Lib/ssl.py is right there on disk. The ordering has been tweaked between releases, so check yours rather than trusting my summary of it.
Find the bad cert
This part is read-only and quick. PowerShell 5.1, no admin needed:
# try to re-parse every cert in the root stores as X.509 DER
$stores = @('Cert:\LocalMachine\Root', 'Cert:\CurrentUser\Root', 'Cert:\LocalMachine\CA')
foreach ($s in $stores) {
if (-not (Test-Path $s)) { continue }
Get-ChildItem $s -ErrorAction SilentlyContinue | ForEach-Object {
try { $null = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($_.RawData) }
catch { "BAD {0} {1} {2}" -f $s, $_.Thumbprint, $_.Subject }
}
}
Whatever the catch prints is your suspect. On a healthy machine you get nothing back, which is exactly what happened when I ran it here.
Export before you delete anything:
$c = Get-ChildItem Cert:\LocalMachine\Root\<THUMBPRINT>
Export-Certificate -Cert $c -FilePath "$env:USERPROFILE\Desktop\suspect.cer"
Then remove it, either through certlm.msc (Trusted Root Certification Authorities, find the thumbprint, delete) or:
Remove-Item -Path Cert:\LocalMachine\Root\<THUMBPRINT> # admin shell
One caveat that matters. If the cert turns out to be your company root for TLS inspection, do not delete it, re-import it properly. A half-imported corporate root will break internal TLS the moment it's gone, and now you have two problems instead of one.
Where do malformed certs come from anyway? Usually a truncated import by an installer, an endpoint agent or AV product, an old tool that wrote DER where the store wanted PEM, or someone hand-copying a root out of a browser.
If you cannot touch the store, skip the scan
For code you own, pass an explicit CA source. Any of cafile, capath or cadata takes a different branch, so load_default_certs never runs:
import ssl, certifi
ctx = ssl.create_default_context(cafile=certifi.where()) # store is never scanned
# from Lib/ssl.py:
# if cafile or capath or cadata:
# context.load_verify_locations(cafile, capath, cadata)
# elif context.verify_mode != CERT_NONE:
# context.load_default_certs(purpose)
For tools you don't own, it's the same idea expressed as whatever config that tool reads. This is also the answer to the other half of the problem that kept showing up in those reports. Behind a TLS-inspecting proxy, exporting your corporate CA to one tool does nothing for the next tool, because each one has its own knob.
| Tool | Setting |
|---|---|
Python ssl
|
SSL_CERT_FILE, SSL_CERT_DIR
|
| requests |
REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE
|
| pip |
PIP_CERT, or --cert
|
| Node.js |
NODE_EXTRA_CA_CERTS (PEM, appended to the bundled roots at launch) |
| npm | npm config set cafile <path> |
| curl |
CURL_CA_BUNDLE, --cacert
|
| git |
GIT_SSL_CAINFO, git config --global http.sslCAInfo <path>
|
| uv |
SSL_CERT_FILE, SSL_CERT_DIR, plus UV_NATIVE_TLS if you want the platform store |
| AWS CLI / boto3 | AWS_CA_BUNDLE |
And please don't "fix" this with verify=False or curl -k. That hides the error and hands the problem to whoever reads your traffic next.
The bit worth remembering
A shared trust store is a single point of failure. One bad byte in an entry you never put there takes down every process that asks for a default context, and the error lands in whichever tool happened to load first. The thing that crashes is rarely the thing that is broken.
I can be wrong about your specific machine, but if the traceback points at _load_windows_store_certs, run the scan above before you downgrade anything. Hope this helps.
Top comments (0)