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
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,15 @@ ssl
the module.
(Contributed by Serhiy Storchaka in :gh:`157513`.)

* As an extraordinary measure and last line of defense, the :mod:`ssl` module
now rejects TLS certificate chains that contain one of a set of compromised
eMudhra intermediate CA keys. Removing trust is primarily the job of the
platform root store; this denylist only blocks connections during certificate
verification while that update has not yet reached the OS, a container image,
or a bundled trust store in various Python packages. Keys are added to the
denylist only under a very high threshold.
(Contributed by Christian Heimes in :gh:`160040`.)

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.

Documenting the change in What's New document makes it harder to backport to other branches. Would it be enough to document it as a Changelog entry instead?

If we keep it in What's New, it should be moved to special sections in old What's New documents (when the change will be backported), such as https://docs.python.org/dev/whatsnew/3.14.html#notable-changes-in-3-14-5.



symtable
--------
Expand Down
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
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
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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
: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.
64 changes: 62 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 @@ -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)
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 @@ -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;
}

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 */
Loading
Loading