Skip to content
Merged
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
9 changes: 9 additions & 0 deletions anaconda.yaml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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.
Expand Down Expand Up @@ -236,6 +242,7 @@ outputs:
- tests/cmake/*
- tests/cython/*
- tests/prefix-replacement/*
- tests/ssl/*
- run_test.py
- test-cert.pem
commands:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
From b299e19c65e03a6c3df58b31f9da4e1f974c81a5 Mon Sep 17 00:00:00 2001
From: Christian Heimes <christian@python.org>
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 @@
<AdditionalOptions Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">/d1trimfile:%SRC_DIR%</AdditionalOptions>
</ClCompile>
</ItemDefinitionGroup>
+ <ItemGroup>
+ <ClInclude Include="..\Modules\_ssl\distrusted_spki.h" />
+ </ItemGroup>
<ItemGroup>
<ClCompile Include="..\Modules\_ssl.c" />
</ItemGroup>
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 @@
<Filter Include="Source Files">
<UniqueIdentifier>{695348f7-e9f6-4fe1-bc03-5f08ffc8095b}</UniqueIdentifier>
</Filter>
+ <Filter Include="Header Files">
+ <UniqueIdentifier>{7c1bd5da-8912-4107-b6ac-f3930f2d90c7}</UniqueIdentifier>
+ </Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{1b18a2e6-040d-46c7-a9ac-ac2ec64fb5d6}</UniqueIdentifier>
</Filter>
@@ -13,6 +16,11 @@
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
+ <ItemGroup>
+ <ClInclude Include="..\Modules\_ssl\distrusted_spki.h">
+ <Filter>Header Files</Filter>
+ </ClInclude>
+ </ItemGroup>
<ItemGroup>
<ResourceCompile Include="..\PC\python_nt.rc">
<Filter>Resource Files</Filter>
42 changes: 42 additions & 0 deletions recipe/run_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Binary file added recipe/tests/ssl/distrusted.zip
Binary file not shown.