diff --git a/pymongo/ocsp_support.py b/pymongo/ocsp_support.py index 24a437460..daa83e39b 100644 --- a/pymongo/ocsp_support.py +++ b/pymongo/ocsp_support.py @@ -15,6 +15,7 @@ """Support for requesting and verifying OCSP responses.""" import logging as _logging +import re as _re from datetime import datetime as _datetime @@ -39,6 +40,7 @@ from cryptography.x509 import ( AuthorityInformationAccess as _AuthorityInformationAccess, ExtendedKeyUsage as _ExtendedKeyUsage, ExtensionNotFound as _ExtensionNotFound, + load_pem_x509_certificate as _load_pem_x509_certificate, TLSFeature as _TLSFeature, TLSFeatureType as _TLSFeatureType) from cryptography.x509.oid import ( @@ -59,12 +61,39 @@ from requests.exceptions import RequestException as _RequestException _LOGGER = _logging.getLogger(__name__) +_CERT_REGEX = _re.compile( + b'-----BEGIN CERTIFICATE[^\r\n]+.+?-----END CERTIFICATE[^\r\n]+', + _re.DOTALL) -def _get_issuer_cert(cert, chain): + +def _load_trusted_ca_certs(cafile): + """Parse the tlsCAFile into a list of certificates.""" + with open(cafile, 'rb') as f: + data = f.read() + + # Load all the certs in the file. + trusted_ca_certs = [] + backend = _default_backend() + for cert_data in _re.findall(_CERT_REGEX, data): + trusted_ca_certs.append( + _load_pem_x509_certificate(cert_data, backend)) + return trusted_ca_certs + + +def _get_issuer_cert(cert, chain, trusted_ca_certs): issuer_name = cert.issuer for candidate in chain: if candidate.subject == issuer_name: return candidate + + # Depending on the server's TLS library, the peer's cert chain may not + # include the self signed root CA. In this case we check the user + # provided tlsCAFile (ssl_ca_certs) for the issuer. + # Remove once we use the verified peer cert chain in PYTHON-2147. + if trusted_ca_certs: + for candidate in trusted_ca_certs: + if candidate.subject == issuer_name: + return candidate return None @@ -232,11 +261,19 @@ def _verify_response(issuer, response): return 1 -def ocsp_callback(conn, ocsp_bytes, user_data): +def _ocsp_callback(conn, ocsp_bytes, user_data): """Callback for use with OpenSSL.SSL.Context.set_ocsp_client_callback.""" - cert = conn.get_peer_certificate().to_cryptography() - chain = [cer.to_cryptography() for cer in conn.get_peer_cert_chain()] - issuer = _get_issuer_cert(cert, chain) + cert = conn.get_peer_certificate() + if cert is None: + _LOGGER.debug("No peer cert?") + return 0 + cert = cert.to_cryptography() + chain = conn.get_peer_cert_chain() + if not chain: + _LOGGER.debug("No peer cert chain?") + return 0 + chain = [cer.to_cryptography() for cer in chain] + issuer = _get_issuer_cert(cert, chain, user_data.trusted_ca_certs) must_staple = False # https://tools.ietf.org/html/rfc7633#section-4.2.3.1 ext = _get_extension(cert, _TLSFeature) diff --git a/pymongo/pyopenssl_context.py b/pymongo/pyopenssl_context.py index aaf0e8562..1011893e2 100644 --- a/pymongo/pyopenssl_context.py +++ b/pymongo/pyopenssl_context.py @@ -32,10 +32,14 @@ from service_identity import ( CertificateError as _SICertificateError, VerificationError as _SIVerificationError) +from cryptography.hazmat.backends import default_backend as _default_backend + from bson.py3compat import _unicode from pymongo.errors import CertificateError as _CertificateError from pymongo.monotonic import time as _time -from pymongo.ocsp_support import ocsp_callback as _ocsp_callback +from pymongo.ocsp_support import ( + _load_trusted_ca_certs, + _ocsp_callback) from pymongo.socket_checker import ( _errno_from_exception, SocketChecker as _SocketChecker) @@ -133,23 +137,31 @@ class _sslConn(_SSL.Connection): total_sent += sent +class _CallbackData(object): + """Data class which is passed to the OCSP callback.""" + def __init__(self): + self.trusted_ca_certs = None + + class SSLContext(object): """A CPython compatible SSLContext implementation wrapping PyOpenSSL's context. """ - __slots__ = ('_protocol', '_ctx', '_check_hostname') + __slots__ = ('_protocol', '_ctx', '_check_hostname', '_callback_data') def __init__(self, protocol): self._protocol = protocol self._ctx = _SSL.Context(self._protocol) self._check_hostname = True + self._callback_data = _CallbackData() # OCSP # XXX: Find a better place to do this someday, since this is client # side configuration and wrap_socket tries to support both client and # server side sockets. self._ctx.set_ocsp_client_callback( - callback=_ocsp_callback, data=None) + callback=_ocsp_callback, data=self._callback_data) + @property def protocol(self): @@ -229,6 +241,7 @@ class SSLContext(object): ssl.CERT_NONE. """ self._ctx.load_verify_locations(cafile, capath) + self._callback_data.trusted_ca_certs = _load_trusted_ca_certs(cafile) def set_default_verify_paths(self): """Specify that the platform provided CA certificates are to be used diff --git a/test/certificates/trusted-ca.pem b/test/certificates/trusted-ca.pem new file mode 100644 index 000000000..a6f6f312d --- /dev/null +++ b/test/certificates/trusted-ca.pem @@ -0,0 +1,82 @@ +# CA bundle file used to test tlsCAFile loading for OCSP. +# Copied from the server: +# https://github.com/mongodb/mongo/blob/r4.3.4/jstests/libs/trusted-ca.pem + +# Autogenerated file, do not edit. +# Generate using jstests/ssl/x509/mkcert.py --config jstests/ssl/x509/certs.yml trusted-ca.pem +# +# CA for alternate client/server certificate chain. +-----BEGIN CERTIFICATE----- +MIIDojCCAooCBG585gswDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxETAP +BgNVBAgMCE5ldyBZb3JrMRYwFAYDVQQHDA1OZXcgWW9yayBDaXR5MRAwDgYDVQQK +DAdNb25nb0RCMQ8wDQYDVQQLDAZLZXJuZWwxHzAdBgNVBAMMFlRydXN0ZWQgS2Vy +bmVsIFRlc3QgQ0EwHhcNMTkwOTI1MjMyNzQxWhcNMzkwOTI3MjMyNzQxWjB8MQsw +CQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxFjAUBgNVBAcMDU5ldyBZb3Jr +IENpdHkxEDAOBgNVBAoMB01vbmdvREIxDzANBgNVBAsMBktlcm5lbDEfMB0GA1UE +AwwWVHJ1c3RlZCBLZXJuZWwgVGVzdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBANlRxtpMeCGhkotkjHQqgqvO6O6hoRoAGGJlDaTVtqrjmC8nwySz +1nAFndqUHttxS3A5j4enOabvffdOcV7+Z6vDQmREF6QZmQAk81pmazSc3wOnRiRs +AhXjld7i+rhB50CW01oYzQB50rlBFu+ONKYj32nBjD+1YN4AZ2tuRlbxfx2uf8Bo +Zowfr4n9nHVcWXBLFmaQLn+88WFO/wuwYUOn6Di1Bvtkvqum0or5QeAF0qkJxfhg +3a4vBnomPdwEXCgAGLvHlB41CWG09EuAjrnE3HPPi5vII8pjY2dKKMomOEYmA+KJ +AC1NlTWdN0TtsoaKnyhMMhLWs3eTyXL7kbkCAwEAAaMxMC8wDAYDVR0TBAUwAwEB +/zAfBgNVHREEGDAWgglsb2NhbGhvc3SCCTEyNy4wLjAuMTANBgkqhkiG9w0BAQsF +AAOCAQEAQk56MO9xAhtO077COCqIYe6pYv3uzOplqjXpJ7Cph7GXwQqdFWfKls7B +cLfF/fhIUZIu5itStEkY+AIwht4mBr1F5+hZUp9KZOed30/ewoBXAUgobLipJV66 +FKg8NRtmJbiZrrC00BSO+pKfQThU8k0zZjBmNmpjxnbKZZSFWUKtbhHV1vujver6 +SXZC7R6692vLwRBMoZxhgy/FkYRdiN0U9wpluKd63eo/O02Nt6OEMyeiyl+Z3JWi +8g5iHNrBYGBbGSnDOnqV6tjEY3eq600JDWiodpA1OQheLi78pkc/VQZwof9dyBCm +6BoCskTjip/UB+vIhdPFT9sgUdgDTg== +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUcbaTHghoZKL +ZIx0KoKrzujuoaEaABhiZQ2k1baq45gvJ8Mks9ZwBZ3alB7bcUtwOY+Hpzmm7333 +TnFe/merw0JkRBekGZkAJPNaZms0nN8Dp0YkbAIV45Xe4vq4QedAltNaGM0AedK5 +QRbvjjSmI99pwYw/tWDeAGdrbkZW8X8drn/AaGaMH6+J/Zx1XFlwSxZmkC5/vPFh +Tv8LsGFDp+g4tQb7ZL6rptKK+UHgBdKpCcX4YN2uLwZ6Jj3cBFwoABi7x5QeNQlh +tPRLgI65xNxzz4ubyCPKY2NnSijKJjhGJgPiiQAtTZU1nTdE7bKGip8oTDIS1rN3 +k8ly+5G5AgMBAAECggEAS7GjLKgT88reSzUTgubHquYf1fZwMak01RjTnsVdoboy +aMJVwzPsjgo2yEptUQvuNcGmz54cg5vJaVlmPaspGveg6WGaRmswEo/MP4GK98Fo +IFKkKM2CEHO74O14XLN/w8yFA02+IdtM3X/haEFE71VxXNmwawRXIBxN6Wp4j5Fb +mPLKIspnWQ/Y/Fn799sCFAzX5mKkbCt1IEgKssgQQEm1UkvmCkcZE+mdO/ErYP8A +COO0LpM+TK6WQY2LKiteeCCiosTZFb1GO7MkXrRP5uOBZKaW5kq1R0b6PcopJPCM +OcYF0Zli6KB7oiQLdXgU2jCaxYOnuRb6RYh2l7NvAQKBgQD6CZ9TKOn/EUQtukyw +pvYTyt1hoLXqYGcbRtLc1gcC+Z2BD28hd3eD/mEUv+g/8bq/OP4wYV9X+VRvR8xN +MmfAG/sJeOCOClz1A1TyNeA+G0GZ25qWHyHQ2W4WlSG1CXQgxGzU6wo/t6wiVW5R +O4jplFVEOXznf4vmVfBJK50R2QKBgQDegGxm23jF2N5sIYDZ14oxms8bbjPz8zH6 +tiIRYNGbSzI7J4KFGY2HiBwtf1yxS22HBL69Y1WrEzGm1vm4aZG/GUwBzI79QZAO ++YFIGaIrdlv12Zm6lpJMmAWlOs9XFirC17oQEwOQFweOdQSt7F/+HMZOigdikRBV +pK+8Kfay4QKBgQDarDevHwUmkg8yftA7Xomv3aenjkoK5KzH6jTX9kbDj1L0YG8s +sbLQuVRmNUAFTH+qZUnJPh+IbQIvIHfIu+CI3u+55QFeuCl8DqHoAr5PEr9Ys/qK +eEe2w7HIBj0oe1AYqDEWNUkNWLEuhdCpMowW3CeGN1DJlX7gvyAang4MYQKBgHwM +aWNnFQxo/oiWnTnWm2tQfgszA7AMdF7s0E2UBwhnghfMzU3bkzZuwhbznQATp3rR +QG5iRU7dop7717ni0akTN3cBTu8PcHuIy3UhJXLJyDdnG/gVHnepgew+v340E58R +muB/WUsqK8JWp0c4M8R+0mjTN47ShaLZ8EgdtTbBAoGBAKOcpuDfFEMI+YJgn8zX +h0nFT60LX6Lx+zcSDY9+6J6a4n5NhC+weYCDFOGlsLka1SwHcg1xanfrLVjpH7Ok +HDJGLrSh1FP2Rq/oFxZ/OKCjonHLa8IulqD/AA+sqYRbysKNsT3Pi0554F2xFEqQ +z/C84nlT1R2uTCWIxvrnpU2h +-----END PRIVATE KEY----- +# Pre Oct 2019 trusted-ca.pem +# Transitional pending BUILD update. +-----BEGIN CERTIFICATE----- +MIIDpjCCAo6gAwIBAgIDAghHMA0GCSqGSIb3DQEBBQUAMHwxHzAdBgNVBAMTFlRy +dXN0ZWQgS2VybmVsIFRlc3QgQ0ExDzANBgNVBAsTBktlcm5lbDEQMA4GA1UEChMH +TW9uZ29EQjEWMBQGA1UEBxMNTmV3IFlvcmsgQ2l0eTERMA8GA1UECBMITmV3IFlv +cmsxCzAJBgNVBAYTAlVTMB4XDTE2MDMzMTE0NTY1NVoXDTM2MDMzMTE0NTY1NVow +fDEfMB0GA1UEAxMWVHJ1c3RlZCBLZXJuZWwgVGVzdCBDQTEPMA0GA1UECxMGS2Vy +bmVsMRAwDgYDVQQKEwdNb25nb0RCMRYwFAYDVQQHEw1OZXcgWW9yayBDaXR5MREw +DwYDVQQIEwhOZXcgWW9yazELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQCePFHZTydC96SlSHSyu73vw//ddaE33kPllBB9DP2L7yRF +6D/blFmno9fSM+Dfg64VfGV+0pCXPIZbpH29nzJu0DkvHzKiWK7P1zUj8rAHaX++ +d6k0yeTLFM9v+7YE9rHoANVn22aOyDvTgAyMmA0CLn+SmUy6WObwMIf9cZn97Znd +lww7IeFNyK8sWtfsVN4yRBnjr7kKN2Qo0QmWeFa7jxVQptMJQrY8k1PcyVUOgOjQ +ocJLbWLlm9k0/OMEQSwQHJ+d9weUbKjlZ9ExOrm4QuuA2tJhb38baTdAYw3Jui4f +yD6iBAGD0Jkpc+3YaWv6CBmK8NEFkYJD/gn+lJ75AgMBAAGjMTAvMAwGA1UdEwQF +MAMBAf8wHwYDVR0RBBgwFoIJbG9jYWxob3N0ggkxMjcuMC4wLjEwDQYJKoZIhvcN +AQEFBQADggEBADYikjB6iwAUs6sglwkE4rOkeMkJdRCNwK/5LpFJTWrDjBvBQCdA +Y5hlAVq8PfIYeh+wEuSvsEHXmx7W29X2+p4VuJ95/xBA6NLapwtzuiijRj2RBAOG +1EGuyFQUPTL27DR3+tfayNykDclsVDNN8+l7nt56j8HojP74P5OMHtn+6HX5+mtF +FfZMTy0mWguCsMOkZvjAskm6s4U5gEC8pYEoC0ZRbfUdyYsxZe/nrXIFguVlVPCB +XnfB/0iG9t+VH5cUVj1LP9skXTW4kXfhQmljUuo+EVBNR6n2nfTnpoC65WeAgHV4 +V+s9mJsUv2x72KtKYypqEVT0gaJ1WIN9N1s= +-----END CERTIFICATE----- diff --git a/test/test_ssl.py b/test/test_ssl.py index c2b8deae3..227d0db37 100644 --- a/test/test_ssl.py +++ b/test/test_ssl.py @@ -39,7 +39,9 @@ from test import (IntegrationTest, SkipTest, unittest, HAVE_IPADDRESS) -from test.utils import remove_all_users, connected +from test.utils import (remove_all_users, + cat_files, + connected) _HAVE_PYOPENSSL = False try: @@ -51,6 +53,11 @@ try: except ImportError: pass +if _HAVE_PYOPENSSL: + from pymongo.ocsp_support import _load_trusted_ca_certs +else: + _load_trusted_ca_certs = None + if HAVE_SSL: import ssl @@ -59,6 +66,7 @@ CERT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), CLIENT_PEM = os.path.join(CERT_PATH, 'client.pem') CLIENT_ENCRYPTED_PEM = os.path.join(CERT_PATH, 'password_protected.pem') CA_PEM = os.path.join(CERT_PATH, 'ca.pem') +CA_BUNDLE_PEM = os.path.join(CERT_PATH, 'trusted-ca.pem') CRL_PEM = os.path.join(CERT_PATH, 'crl.pem') MONGODB_X509_USERNAME = ( "C=US,ST=New York,L=New York City,O=MDB,OU=Drivers,CN=client") @@ -157,6 +165,11 @@ class TestClientSSL(unittest.TestCase): def test_use_openssl_when_available(self): self.assertTrue(_ssl.IS_PYOPENSSL) + @unittest.skipUnless(_HAVE_PYOPENSSL, "Cannot test without PyOpenSSL") + def test_load_trusted_ca_certs(self): + trusted_ca_certs = _load_trusted_ca_certs(CA_BUNDLE_PEM) + self.assertEqual(2, len(trusted_ca_certs)) + class TestSSL(IntegrationTest): @@ -644,6 +657,23 @@ class TestSSL(IntegrationTest): else: self.fail("Invalid certificate accepted.") + def test_connect_with_ca_bundle(self): + def remove(path): + try: + os.remove(path) + except OSError: + pass + + temp_ca_bundle = os.path.join(CERT_PATH, 'trusted-ca-bundle.pem') + self.addCleanup(remove, temp_ca_bundle) + # Add the CA cert file to the bundle. + cat_files(temp_ca_bundle, CA_BUNDLE_PEM, CA_PEM) + with MongoClient('localhost', + tls=True, + tlsCertificateKeyFile=CLIENT_PEM, + tlsCAFile=temp_ca_bundle) as client: + self.assertTrue(client.admin.command('ismaster')) + if __name__ == "__main__": unittest.main() diff --git a/test/utils.py b/test/utils.py index c1f7b9a57..05e9be12c 100644 --- a/test/utils.py +++ b/test/utils.py @@ -20,6 +20,7 @@ import contextlib import functools import os import re +import shutil import sys import threading import time @@ -880,3 +881,11 @@ def server_name_to_type(name): if name == 'PossiblePrimary': return SERVER_TYPE.Unknown return getattr(SERVER_TYPE, name) + + +def cat_files(dest, *sources): + """Cat multiple files into dest.""" + with open(dest, 'wb') as fdst: + for src in sources: + with open(src, 'rb') as fsrc: + shutil.copyfileobj(fsrc, fdst)