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)}")