Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file added Lib/test/certdata/distrusted.zip
Binary file not shown.
106 changes: 105 additions & 1 deletion Lib/test/certdata/make_ssl_certs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pprint
import shutil
import tempfile
import zipfile
from subprocess import *

startdate = "20180829142316Z"
Expand Down Expand Up @@ -118,7 +119,6 @@

here = os.path.abspath(os.path.dirname(__file__))


def make_cert_key(cmdlineargs, hostname, sign=False, extra_san='',
ext='req_x509_extensions_full', key='rsa:3072'):
print("creating cert for " + hostname)
Expand Down Expand Up @@ -230,6 +230,108 @@ def write_cert_reference(path):
print(refdata, file=f)


# The distrusted test material (compromised eMudhra CA certs and a leaf signed
# by them) lives in a password-protected zip so it doesn't trip security
# scanners that cannot tell internal test fixtures from production secrets and
# flag any certificate or private key they find. It is encrypted, not merely
# zipped, because such scanners read inside plain archives. The password is
# fixed, public, and not a secret.
DISTRUSTED_ZIP = 'distrusted.zip'
DISTRUSTED_ZIP_DIR = 'distrusted'
DISTRUSTED_ZIP_PW = 'Brave Sir Robin'

# ASN.1 template for the intermediate's compromised key: secp384r1 scalar 4.
# openssl derives the public key when the SEC1 structure omits it.
distrusted_key_template = """
asn1 = SEQUENCE:eckey
[eckey]
version = INTEGER:1
priv = FORMAT:HEX,OCTETSTRING:{scalar}
params = EXPLICIT:0,OID:secp384r1
"""


def make_distrusted_spki_cert(cmdlineargs):
# leaf signed by the distrusted intermediate, chains to the distrusted root
print("creating distrusted-SPKI test cert")
# The standard library can read but not write encrypted zip files, so we
# need the Info-ZIP "zip" command to repackage the archive.
if shutil.which('zip') is None:
raise SystemExit(
f"the 'zip' command is required to write the encrypted {DISTRUSTED_ZIP} "
"(the zipfile module cannot create encrypted archives); "
"please install Info-ZIP's zip utility"
)

# the root and intermediate are real certs we cannot re-mint; carry them
# over from the existing archive
pw = DISTRUSTED_ZIP_PW.encode()
with zipfile.ZipFile(DISTRUSTED_ZIP) as z:
root = z.read(DISTRUSTED_ZIP_DIR + '/distrusted_root.pem', pw)
intermediate = z.read(DISTRUSTED_ZIP_DIR + '/distrusted_intermediate.pem', pw)

with tempfile.TemporaryDirectory() as workdir:
stage = os.path.join(workdir, DISTRUSTED_ZIP_DIR)
os.mkdir(stage)
req_file = os.path.join(workdir, 'req.cnf')
key_file = os.path.join(workdir, 'leaf.key')
csr_file = os.path.join(workdir, 'leaf.csr')
cert_file = os.path.join(workdir, 'leaf.pem')
cakey_conf = os.path.join(workdir, 'cakey.cnf')
cakey_der = os.path.join(workdir, 'cakey.der')
cakey_file = os.path.join(workdir, 'cakey.pem')

inter_file = os.path.join(stage, 'distrusted_intermediate.pem')
with open(os.path.join(stage, 'distrusted_root.pem'), 'wb') as f:
f.write(root)
with open(inter_file, 'wb') as f:
f.write(intermediate)

# recreate the intermediate's key (secp384r1 scalar 4) on the fly
with open(cakey_conf, 'w') as f:
f.write(distrusted_key_template.format(scalar='00' * 47 + '04'))
check_call(['openssl', 'asn1parse', '-genconf', cakey_conf,
'-out', cakey_der, '-noout'])
check_call(['openssl', 'pkey', '-in', cakey_der,
'-inform', 'DER', '-out', cakey_file])

req = req_template.format(
hostname='localhost',
extra_san='',
startdate=startdate,
enddate=cmdlineargs.enddate,
days=cmdlineargs.days
)
with open(req_file, 'w') as f:
f.write(req)
# ECC leaf to match the ECC intermediate
check_call(['openssl', 'req', '-new', '-nodes', '-days', cmdlineargs.days,
'-newkey', 'param:secp384r1.pem', '-keyout', key_file,
'-config', req_file, '-out', csr_file])
# sign with the distrusted intermediate, not the test CA
check_call(['openssl', 'ca', '-config', req_file,
'-extensions', 'req_x509_extensions_full',
'-cert', inter_file, '-keyfile', cakey_file,
'-out', cert_file, '-outdir', 'cadir',
'-policy', 'policy_anything', '-batch', '-infiles', csr_file])
with open(key_file) as f:
key = f.read()
with open(cert_file) as f:
cert = f.read()
# server sends leaf + intermediate, client trusts the root
with open(os.path.join(stage, 'distrusted_keycert.pem'), 'w') as f:
f.write(key)
f.write(cert)
f.write(intermediate.decode())

# repackage the encrypted archive (zipfile cannot write encryption)
zippath = os.path.join(here, DISTRUSTED_ZIP)
if os.path.exists(zippath):
os.remove(zippath)
check_call(['zip', '-q', '-X', '-P', DISTRUSTED_ZIP_PW, '-r',
zippath, DISTRUSTED_ZIP_DIR], cwd=workdir)


if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Make the custom certificate and private key files used by test_ssl and friends.')
parser.add_argument('--days', default=days_default)
Expand Down Expand Up @@ -320,6 +422,8 @@ def write_cert_reference(path):
f.write(key)
f.write(cert)

make_distrusted_spki_cert(cmdlineargs)

unmake_ca()
print("Writing out reference data for Lib/test/test_ssl.py and Lib/test/test_asyncio/utils.py")
write_cert_reference('keycert.pem')
Expand Down
37 changes: 37 additions & 0 deletions Lib/test/test_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import socket
import select
import struct
import tempfile
import time
import zipfile
import enum
import gc
import http.client
Expand Down Expand Up @@ -130,6 +132,19 @@ def data_file(*name):
LEAF_MISSING_AKI_CERTFILE_HOSTNAME = "example.com"
LEAF_MISSING_AKI_CA = data_file("leaf-missing-aki.ca.pem")

# gh-160040: a leaf signed by a compromised eMudhra intermediate whose public
# key is on the _ssl distrusted list. The chain to distrusted_root.pem is
# otherwise valid.
# The PEMs live in a password-protected zip so the compromised eMudhra certs
# don't trip security scanners that cannot tell internal test fixtures from
# production secrets and flag any certificate or private key they find. It is
# encrypted, not merely zipped, because such scanners read inside plain archives.
# The password is fixed, public, and not a secret; it only keeps the archive
# from storing the PEMs in the clear.
DISTRUSTED_SPKI_ZIP = data_file("distrusted.zip")
CHOSEN_KNIGHT = b"Brave Sir Robin" # not a secret, see comment above

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.

Maybe rename to DISTRUSTED_SPKI_PW to make the constant usage more obvious.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's on purpose. A string like FOO_PW can trigger security scanners that look for hard-coded secrets in code.

DISTRUSTED_SPKI_HOSTNAME = "localhost"

# Same certificate as pycacert.pem, but without extra text in file
SIGNING_CA = data_file("capath", "ceff1710.0")
# cert with all kinds of subject alt names
Expand Down Expand Up @@ -2280,6 +2295,28 @@ def test_unwrap(self):
c_in.write(s_out.read())
client.unwrap()

def test_distrusted_spki(self):
# gh-160040: the server chain is otherwise valid, but the eMudhra
# intermediate's public key is on the _ssl distrusted list, so
# verification rejects it.
client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
# the contexts load the PEMs now, so the files are only needed here
with tempfile.TemporaryDirectory() as tmpdir:
with zipfile.ZipFile(DISTRUSTED_SPKI_ZIP) as z:
z.extractall(tmpdir, pwd=CHOSEN_KNIGHT)
certdir = os.path.join(tmpdir, "distrusted")
client_context.load_verify_locations(
os.path.join(certdir, "distrusted_root.pem"))
server_context.load_cert_chain(
os.path.join(certdir, "distrusted_keycert.pem"))

with self.assertRaises(ssl.SSLCertVerificationError) as cm:
connected_bio_pair(client_context, server_context,
DISTRUSTED_SPKI_HOSTNAME)
# X509_V_ERR_CERT_REJECTED

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.

Would it be possible to expose X509_V_ERR_CERT_REJECTED constant as _ssl.X509_V_ERR_CERT_REJECTED, instead of using hardcoded numbers?

@tiran tiran Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sure, it's possible. But does it make sense?

The ssl module does not expose any X509_V_ERR error constants. Does it make sense to complicate the C code just to remove a hard-coded constant from an internal unit test?

self.assertEqual(cm.exception.verify_code, 28)

def test_sni_callback_context_released_and_callback_raises(self):
# Variant of the test below without a HelloRetryRequest: the callback
# switches the connection to another context, drops the last
Expand Down
2 changes: 1 addition & 1 deletion Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -3489,7 +3489,7 @@ MODULE__HMAC_DEPS=$(srcdir)/Modules/hashlib.h $(LIBHACL_HMAC_HEADERS) $(LIBHACL_
MODULE__HMAC_LDEPS=$(LIBHACL_HMAC_LIB_@LIBHACL_LDEPS_LIBTYPE@)

MODULE__SOCKET_DEPS=$(srcdir)/Modules/socketmodule.h $(srcdir)/Modules/addrinfo.h $(srcdir)/Modules/getaddrinfo.c $(srcdir)/Modules/getnameinfo.c
MODULE__SSL_DEPS=$(srcdir)/Modules/_ssl.h $(srcdir)/Modules/_openssl_mem.h $(srcdir)/Modules/_ssl/cert.c $(srcdir)/Modules/_ssl/debughelpers.c $(srcdir)/Modules/_ssl/misc.c $(srcdir)/Modules/_ssl_data_111.h $(srcdir)/Modules/_ssl_data_300.h $(srcdir)/Modules/socketmodule.h
MODULE__SSL_DEPS=$(srcdir)/Modules/_ssl.h $(srcdir)/Modules/_openssl_mem.h $(srcdir)/Modules/_ssl/cert.c $(srcdir)/Modules/_ssl/debughelpers.c $(srcdir)/Modules/_ssl/misc.c $(srcdir)/Modules/_ssl/distrusted_spki.h $(srcdir)/Modules/_ssl_data_111.h $(srcdir)/Modules/_ssl_data_300.h $(srcdir)/Modules/socketmodule.h
MODULE__TESTCAPI_DEPS=$(srcdir)/Modules/_testcapi/parts.h $(srcdir)/Modules/_testcapi/util.h
MODULE__TESTLIMITEDCAPI_DEPS=$(srcdir)/Modules/_testlimitedcapi/testcapi_long.h $(srcdir)/Modules/_testlimitedcapi/parts.h $(srcdir)/Modules/_testlimitedcapi/util.h
MODULE__TESTINTERNALCAPI_DEPS=$(srcdir)/Modules/_testinternalcapi/parts.h $(srcdir)/Parser/tokenizer/source.h $(srcdir)/Parser/tokenizer/types.h $(srcdir)/Python/ceval.h $(srcdir)/Modules/_testinternalcapi/test_targets.h $(srcdir)/Modules/_testinternalcapi/test_cases.c.h
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.. cve: CVE-2026-115557

:mod:`ssl` now rejects TLS certificate chains that contain any of a set of
Comment thread
sethmlarson marked this conversation as resolved.
compromised eMudhra intermediate CA keys. This is an extraordinary measure and
a last line of defense: removing trust is primarily the job of the platform root
store, and the built-in denylist only blocks connections while that update has
not yet reached the OS, a container image, or a bundled trust store in various
Python packages. Entries are added to the denylist only under a very high
threshold.
The denylist matches on the public key (the SHA-256 digest of the :rfc:`5280`
``SubjectPublicKeyInfo``), so it also covers the cross-signed certificates that
reuse one of the distrusted keys.
67 changes: 65 additions & 2 deletions Modules/_ssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
/* Include OpenSSL header files */
#include "openssl/rsa.h"
#include "openssl/crypto.h"
#include "openssl/evp.h"
#include "openssl/x509.h"
#include "openssl/x509v3.h"
#include "openssl/pem.h"
Expand Down Expand Up @@ -976,7 +977,9 @@ newPySSLSocket(PySSLContext *sslctx, PySocketSockObject *sock,
int mode = SSL_get_verify_mode(self->ssl);
if (mode & SSL_VERIFY_PEER) {
mode |= SSL_VERIFY_POST_HANDSHAKE;
SSL_set_verify(self->ssl, mode, NULL);
/* Keep ctx verify callback (distrusted SPKI check) */
SSL_set_verify(self->ssl, mode,
SSL_get_verify_callback(self->ssl));
}
} else {
/* client socket */
Expand Down Expand Up @@ -3375,6 +3378,66 @@ static PyType_Spec PySSLSocket_spec = {
* _SSLContext objects
*/

#include "_ssl/distrusted_spki.h"

/* Returns 1 if the SHA-256 of the cert's SPKI is on the distrusted list,
0 if not (okay), -1 on an internal error. */
static int
_cert_has_distrusted_spki(X509 *cert)
{
unsigned char *spki = NULL;
int spki_len = i2d_X509_PUBKEY(X509_get_X509_PUBKEY(cert), &spki);
if (spki_len <= 0 || spki == NULL) {

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.

Would it make sense to log a warning to stderr in debug mode? Currently, the caller gets X509_V_ERR_UNSPECIFIED which is not helpful. Same remark for the return -1; below.

I'm not sure if my idea is a good idea, I don't think that we do that in other places in _ssl.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It does not make sense.

The error condition should never ever trigger in the callback. SPKI is a mandatory field. Every certificate must have a public key. I included the error check because it's good practice to check every return value.

return -1;
}

unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int digest_len = 0;
int ok = EVP_Digest(spki, (size_t)spki_len, digest, &digest_len,
EVP_sha256(), NULL);
OPENSSL_free(spki);
assert(sizeof(_distrusted_spki[0]) == SHA256_DIGEST_LENGTH);
if (!ok
|| digest_len != SHA256_DIGEST_LENGTH
|| sizeof(_distrusted_spki[0]) != SHA256_DIGEST_LENGTH) {
return -1;
}

for (size_t i = 0; i < Py_ARRAY_LENGTH(_distrusted_spki); i++) {
if (memcmp(digest, _distrusted_spki[i], SHA256_DIGEST_LENGTH) == 0) {
return 1;
}
}
return 0;
}

/* Verify callback, called once per cert. Reject distrusted SPKIs. */
static int
_ssl_verify_callback(int preverify_ok, X509_STORE_CTX *x509_ctx)
Comment thread
tiran marked this conversation as resolved.
{
/* Don't override an error OpenSSL already reported for this certificate. */
if (!preverify_ok) {
return preverify_ok;
}
X509 *cert = X509_STORE_CTX_get_current_cert(x509_ctx);
if (cert == NULL) {
return preverify_ok;
}
switch (_cert_has_distrusted_spki(cert)) {
case 0:
/* okay: not on the distrusted list */
return preverify_ok;
case 1:
/* rejected: public key is on the distrusted list */
X509_STORE_CTX_set_error(x509_ctx, X509_V_ERR_CERT_REJECTED);
return 0;
default:
/* internal error */
X509_STORE_CTX_set_error(x509_ctx, X509_V_ERR_UNSPECIFIED);
return 0;
}
}

static int
_set_verify_mode(PySSLContext *self, enum py_ssl_cert_requirements n)
{
Expand All @@ -3399,7 +3462,7 @@ _set_verify_mode(PySSLContext *self, enum py_ssl_cert_requirements n)
/* bpo-37428: newPySSLSocket() sets SSL_VERIFY_POST_HANDSHAKE flag for
* server sockets and SSL_set_post_handshake_auth() for client. */

SSL_CTX_set_verify(self->ctx, mode, NULL);
SSL_CTX_set_verify(self->ctx, mode, _ssl_verify_callback);
return 0;
}

Expand Down
75 changes: 75 additions & 0 deletions Modules/_ssl/distrusted_spki.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* Denylist of distrusted SubjectPublicKeyInfo (SPKI).
*
* Verification rejects any chain with a cert whose SPKI SHA-256 digest is in
* the _distrusted_spki table. We match the key, not a cert fingerprint, so
* cross-signs that reuse the key are blocked too.
*
* This is an extraordinary measure and last line of defense. Removing trust is
* primarily the job of the platform root store. This list only blocks keys while
* that update is still propagating to the OS, container images, or root CAs
* bundled in Python packages. The threshold for adding an entry is very high.
*
* See https://github.com/python/cpython/issues/160040 for the eMudhra discussion.
*
* To block another key, add the SHA-256 digest bytes of its SPKI:
*
* $ openssl x509 -in cert.pem -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | xxd -i
*/
#ifndef Py_SSL_DISTRUSTED_SPKI_H
#define Py_SSL_DISTRUSTED_SPKI_H

#include "openssl/sha.h"

static const unsigned char _distrusted_spki[][SHA256_DIGEST_LENGTH] = {
/* Compromised eMudhra intermediates, https://emudhra.we-have-a-problem.com/ */
/* CN=EM OV TLS CA - G3B,O=eMudhra BV,C=NL */
{
0x43, 0xbf, 0xfa, 0x19, 0xd6, 0xef, 0x65, 0x14, 0x51, 0xf6, 0x84, 0x1e,
0x01, 0x99, 0x31, 0xe9, 0x6f, 0x6f, 0x2e, 0xae, 0xff, 0x28, 0xab, 0x4b,
0x6f, 0x1c, 0xe4, 0x57, 0x6d, 0xe2, 0x71, 0x7b,
},
/* CN=EM DV TLS CA - G3C,O=eMudhra Inc,C=US */
{
0x49, 0xdd, 0x35, 0x3d, 0xc2, 0xb9, 0x19, 0xa3, 0x77, 0x06, 0x45, 0x11,
0x7d, 0x0e, 0x60, 0xc1, 0x61, 0x15, 0xe7, 0xd1, 0xbc, 0x25, 0x9e, 0xef,
0xe7, 0x34, 0xb0, 0x0e, 0xa4, 0x34, 0xfe, 0x5f,
},
/* CN=EM EV TLS CA - G3C,O=eMudhra Inc,C=US */
{
0x5c, 0x06, 0x60, 0xa2, 0xb1, 0x64, 0x91, 0x0d, 0x8f, 0xf5, 0x89, 0xc1,
0x8e, 0x14, 0x55, 0x2e, 0x9e, 0x0e, 0xc2, 0x37, 0xdc, 0xef, 0xe2, 0xcb,
0x73, 0x6d, 0x41, 0xa4, 0x81, 0x1f, 0x81, 0xa0,
},
/* CN=EM OV TLS CA - G3C,O=eMudhra Inc,C=US */
{
0x63, 0xec, 0xef, 0xee, 0x43, 0x1d, 0x49, 0x52, 0x3b, 0xc1, 0x13, 0x6c,
0x4e, 0x72, 0xd0, 0x20, 0x6a, 0x47, 0x70, 0x86, 0xca, 0xf6, 0x06, 0xcd,
0x5c, 0xf0, 0x25, 0x68, 0x6c, 0x10, 0x9c, 0xf8,
},
/* CN=EM OV CS CA - G3C,O=eMudhra Inc,C=US */
{
0x92, 0xc0, 0x63, 0xd6, 0x7e, 0x8f, 0x14, 0xdb, 0x71, 0xd2, 0x88, 0xe1,
0x12, 0x66, 0xff, 0xda, 0xf6, 0x9c, 0xbf, 0x11, 0x5f, 0x7b, 0xab, 0x14,
0xf3, 0xbc, 0x2f, 0x68, 0xd2, 0x5e, 0x0e, 0x30,
},
/* CN=EM EV TLS CA - G3B,O=eMudhra BV,C=NL */
{
0xe0, 0x25, 0x4c, 0x7f, 0xcd, 0xc2, 0x65, 0xe2, 0xf0, 0xf8, 0xa4, 0x28,
0xda, 0xbb, 0x75, 0xab, 0x4a, 0xe0, 0x39, 0xb4, 0x87, 0x2d, 0x62, 0xf5,
0xee, 0x57, 0xf0, 0x87, 0x7d, 0x5a, 0x8a, 0x41,
},
/* CN=EM DV TLS CA - G3B,O=eMudhra BV,C=NL */
{
0xeb, 0xed, 0x64, 0x75, 0xc2, 0xc2, 0xd8, 0x0f, 0xcb, 0x19, 0x9f, 0x3f,
0xe8, 0xdd, 0x6e, 0x6b, 0x3c, 0xe6, 0x0b, 0x77, 0xeb, 0x15, 0xd4, 0xe4,
0x2c, 0x3d, 0xec, 0x0c, 0x6f, 0x5b, 0x51, 0x05,
},
/* CN=EM OV CS CA - G3B,O=eMudhra BV,C=NL */
{
0xec, 0x4f, 0xfa, 0x1a, 0xd0, 0xac, 0x88, 0x08, 0x13, 0x14, 0xb7, 0xaa,
0xff, 0xef, 0x6a, 0x91, 0xf9, 0x7c, 0xd5, 0x72, 0x91, 0xbb, 0x5b, 0xa6,
0x86, 0xc5, 0x41, 0x5f, 0x5e, 0xb4, 0xf6, 0xb6,
},
};

#endif /* !Py_SSL_DISTRUSTED_SPKI_H */
1 change: 1 addition & 0 deletions PCbuild/_ssl.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="..\Modules\_openssl_mem.h" />
<ClInclude Include="..\Modules\_ssl\distrusted_spki.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="..\Modules\_ssl.c" />
Expand Down
Loading
Loading