diff --git a/anaconda.yaml b/anaconda.yaml new file mode 100644 index 000000000..43a5a9890 --- /dev/null +++ b/anaconda.yaml @@ -0,0 +1,9 @@ +anaconda_config_version: 1 +upstream_sources: + - webpage: + url: https://www.python.org/ftp/python/ + regex: href="(\d+(?:\.\d+)+)/" + include_regex: ^3\.10\.\d+(?:[a-z][a-z0-9]*)?$ + packages: + - python + - libpython-static diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 23d3f5e2c..6b713b257 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -4,7 +4,7 @@ {% set ver2 = '.'.join(version.split('.')[0:2]) %} {% set ver2nd = ''.join(version.split('.')[0:2]) %} {% set ver3nd = ''.join(version.split('.')[0:3]) %} -{% set build_number = "0" %} +{% set build_number = "1" %} {% set channel_targets = ('abc', 'def') %} # this makes the linter happy @@ -90,6 +90,10 @@ source: - patches/0027-Unvendor-expat.patch # https://github.com/python/cpython/pull/29791 - patches/0029-Fix-ctypes-fielddesc-init-for-external-libffi.patch + # CVE-2026-115557 (gh-160040): distrust compromised eMudhra intermediate + # CA keys. Backport of CPython 3.11 commit 7dd8b4736a1419750b94a388e6d158be15836fe6; + # 3.10 is EOL, so upstream has no fixed 3.10 release. + - patches/0030-Distrust-compromised-eMudhra-intermediate-CA-keys.patch build: number: {{ build_number }} @@ -110,6 +114,8 @@ outputs: script: build_base.bat # [win] build: number: {{ build_number }} + # win-arm64 ships Python >=3.12 only (aggregate CBC) + skip: true # [win and arm64] activate_in_script: true # Windows has issues updating python if conda is using files itself. # Copy rather than link. @@ -236,6 +242,7 @@ outputs: - tests/cmake/* - tests/cython/* - tests/prefix-replacement/* + - tests/ssl/* - run_test.py - test-cert.pem commands: @@ -301,6 +308,8 @@ outputs: script: build_static.bat # [win] build: number: {{ build_number }} + # win-arm64 ships Python >=3.12 only (aggregate CBC) + skip: true # [win and arm64] activate_in_script: true {% if 'conda-forge' in channel_targets %} ignore_run_exports: diff --git a/recipe/patches/0030-Distrust-compromised-eMudhra-intermediate-CA-keys.patch b/recipe/patches/0030-Distrust-compromised-eMudhra-intermediate-CA-keys.patch new file mode 100644 index 000000000..b7d3f897f --- /dev/null +++ b/recipe/patches/0030-Distrust-compromised-eMudhra-intermediate-CA-keys.patch @@ -0,0 +1,249 @@ +From b299e19c65e03a6c3df58b31f9da4e1f974c81a5 Mon Sep 17 00:00:00 2001 +From: Christian Heimes +Date: Thu, 29 Oct 2026 11:09:28 -0600 +Subject: [PATCH] gh-160040: Distrust compromised eMudhra intermediate CA keys + (CVE-2026-115557) + +Backport of the 3.11 fix (commit 7dd8b4736a1419750b94a388e6d158be15836fe6, +PR gh-160042) to 3.10, which reached end of life before a fixed release +was published. Adds a denylist of compromised SubjectPublicKeyInfo +SHA-256 digests and installs a verify callback that rejects any peer chain +containing one of those keys. + +Adapted for 3.10: no Makefile.pre.in MODULE__SSL_DEPS entry exists and the +older Visual Studio project layout is used, so the Visual Studio header +entries are added without the upstream ItemGroup reordering. + +(cherry picked from commit 7dd8b4736a1419750b94a388e6d158be15836fe6) +--- + Modules/_ssl.c | 67 ++++++++++++++++++++++++++++-- + Modules/_ssl/distrusted_spki.h | 75 ++++++++++++++++++++++++++++++++++ + PCbuild/_ssl.vcxproj | 3 ++ + PCbuild/_ssl.vcxproj.filters | 8 ++++ + 4 files changed, 149 insertions(+), 4 deletions(-) + create mode 100644 Modules/_ssl/distrusted_spki.h + +diff --git a/Modules/_ssl.c b/Modules/_ssl.c +index d58bb15..2b409bb 100644 +--- a/Modules/_ssl.c ++++ b/Modules/_ssl.c +@@ -54,6 +54,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" +@@ -880,6 +881,7 @@ newPySSLSocket(PySSLContext *sslctx, PySocketSockObject *sock, + int (*verify_cb)(int, X509_STORE_CTX *) = NULL; + verify_cb = SSL_get_verify_callback(self->ssl); + mode |= SSL_VERIFY_POST_HANDSHAKE; ++ /* Keep ctx verify callback (distrusted SPKI check) */ + SSL_set_verify(self->ssl, mode, verify_cb); + } + } else { +@@ -2985,11 +2987,70 @@ 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); ++ 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) ++{ ++ /* 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) + { + int mode; +- int (*verify_cb)(int, X509_STORE_CTX *) = NULL; + + switch(n) { + case PY_SSL_CERT_NONE: +@@ -3010,9 +3071,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. */ + +- /* keep current verify cb */ +- verify_cb = SSL_CTX_get_verify_callback(self->ctx); +- SSL_CTX_set_verify(self->ctx, mode, verify_cb); ++ 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 0000000..bbdb56d +--- /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 f583d57..75bec7c 100644 +--- a/PCbuild/_ssl.vcxproj ++++ b/PCbuild/_ssl.vcxproj +@@ -109,6 +109,9 @@ + /d1trimfile:%SRC_DIR% + + ++ ++ ++ + + + +diff --git a/PCbuild/_ssl.vcxproj.filters b/PCbuild/_ssl.vcxproj.filters +index 2f631dc..37c9202 100644 +--- a/PCbuild/_ssl.vcxproj.filters ++++ b/PCbuild/_ssl.vcxproj.filters +@@ -4,6 +4,9 @@ + + {695348f7-e9f6-4fe1-bc03-5f08ffc8095b} + ++ ++ {7c1bd5da-8912-4107-b6ac-f3930f2d90c7} ++ + + {1b18a2e6-040d-46c7-a9ac-ac2ec64fb5d6} + +@@ -13,6 +16,11 @@ + Source Files + + ++ ++ ++ Header Files ++ ++ + + + Resource Files diff --git a/recipe/run_test.py b/recipe/run_test.py index fa6178e69..40bdedd4c 100644 --- a/recipe/run_test.py +++ b/recipe/run_test.py @@ -104,3 +104,45 @@ der = ssl.PEM_cert_to_DER_cert(pem) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.load_verify_locations(cadata=der) + +# CVE-2026-115557 (gh-160040): a peer chain signed by a compromised eMudhra +# intermediate must be rejected even though the chain itself is valid. +import tempfile +import zipfile + +fixtures = Path(__file__).with_name("tests") / "ssl" +with tempfile.TemporaryDirectory() as tmpdir: + # The archive is encrypted only so the test certs are not stored in the + # clear; the password is fixed and public. + with zipfile.ZipFile(fixtures / "distrusted.zip") as zf: + zf.extractall(tmpdir, pwd=b"Brave Sir Robin") + distrusted = Path(tmpdir) / "distrusted" + client_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + client_ctx.load_verify_locations(str(distrusted / "distrusted_root.pem")) + server_ctx.load_cert_chain(str(distrusted / "distrusted_keycert.pem")) + + c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_ctx.wrap_bio(c_in, c_out, server_hostname="localhost") + server = server_ctx.wrap_bio(s_in, s_out, server_side=True) + try: + for _ in range(5): + try: + client.do_handshake() + except ssl.SSLWantReadError: + pass + if c_out.pending: + s_in.write(c_out.read()) + try: + server.do_handshake() + except ssl.SSLWantReadError: + pass + if s_out.pending: + c_in.write(s_out.read()) + client.do_handshake() + except ssl.SSLCertVerificationError as exc: + assert exc.verify_code == 28, exc # X509_V_ERR_CERT_REJECTED + else: + raise AssertionError("distrusted eMudhra chain was accepted") +print("distrusted SPKI check: OK") diff --git a/recipe/tests/ssl/distrusted.zip b/recipe/tests/ssl/distrusted.zip new file mode 100644 index 000000000..83184329a Binary files /dev/null and b/recipe/tests/ssl/distrusted.zip differ