From 70b927a01db02453fa20c87affa306bcc67a313c Mon Sep 17 00:00:00 2001 From: Bernie Hackett Date: Wed, 27 Jan 2021 16:28:12 -0800 Subject: [PATCH] PYTHON-2508 Improve PyOpenSSL on Windows and macOS --- README.rst | 15 +++------ doc/changelog.rst | 2 ++ doc/examples/tls.rst | 10 ------ pymongo/pyopenssl_context.py | 59 ++++++++++++++++++++++++++++++++---- pymongo/ssl_support.py | 54 +-------------------------------- setup.py | 5 +++ test/test_ssl.py | 28 ++++++++--------- 7 files changed, 77 insertions(+), 96 deletions(-) diff --git a/README.rst b/README.rst index b6db345be..05008492c 100644 --- a/README.rst +++ b/README.rst @@ -110,19 +110,12 @@ Support for mongodb+srv:// URIs requires `dnspython $ python -m pip install pymongo[srv] -TLS / SSL support may require `ipaddress -`_ and `certifi -`_ or `wincertstore -`_ depending on the Python -version in use. The necessary dependencies can be installed along with -PyMongo:: - - $ python -m pip install pymongo[tls] - OCSP (Online Certificate Status Protocol) requires `PyOpenSSL `_, `requests -`_ and `service_identity -`_:: +`_, `service_identity +`_ and may +require `certifi +`_:: $ python -m pip install pymongo[ocsp] diff --git a/doc/changelog.rst b/doc/changelog.rst index ae0e3e65e..7b0d81211 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -63,6 +63,8 @@ Breaking Changes in 4.0 :meth:`~pymongo.collection.Collection.find`, :meth:`~pymongo.collection.Collection.find_one`, and :meth:`~pymongo.cursor.Cursor`. +- The "tls" install extra is no longer necessary or supported and will be + ignored by pip. Notable improvements .................... diff --git a/doc/examples/tls.rst b/doc/examples/tls.rst index 07351dc9d..327453bfd 100644 --- a/doc/examples/tls.rst +++ b/doc/examples/tls.rst @@ -6,16 +6,6 @@ configuration options supported by PyMongo. See `the server documentation `_ to configure MongoDB. -Dependencies -............ - -For connections using TLS/SSL, PyMongo may require third party dependencies as -determined by your version of Python. With PyMongo 3.3+, you can install -PyMongo 3.3+ and any TLS/SSL-related dependencies using the following pip -command:: - - $ python -m pip install pymongo[tls] - .. warning:: Industry best practices recommend, and some regulations require, the use of TLS 1.1 or newer. Though no application changes are required for PyMongo to make use of the newest protocols, some operating systems or diff --git a/pymongo/pyopenssl_context.py b/pymongo/pyopenssl_context.py index 3d5cb933f..2118106e6 100644 --- a/pymongo/pyopenssl_context.py +++ b/pymongo/pyopenssl_context.py @@ -18,14 +18,15 @@ context. import socket as _socket import ssl as _stdlibssl -import time +import sys as _sys +import time as _time from errno import EINTR as _EINTR -# service_identity requires this for py27, so it should always be available from ipaddress import ip_address as _ip_address -from OpenSSL import SSL as _SSL +from cryptography.x509 import load_der_x509_certificate as _load_der_x509_certificate +from OpenSSL import crypto as _crypto, SSL as _SSL from service_identity.pyopenssl import ( verify_hostname as _verify_hostname, verify_ip_address as _verify_ip_address) @@ -33,7 +34,9 @@ from service_identity import ( CertificateError as _SICertificateError, VerificationError as _SIVerificationError) -from pymongo.errors import CertificateError as _CertificateError +from pymongo.errors import ( + CertificateError as _CertificateError, + ConfigurationError as _ConfigurationError) from pymongo.ocsp_support import ( _load_trusted_ca_certs, _ocsp_callback) @@ -41,6 +44,12 @@ from pymongo.ocsp_cache import _OCSPCache from pymongo.socket_checker import ( _errno_from_exception, SocketChecker as _SocketChecker) +try: + import certifi + _HAVE_CERTIFI = True +except ImportError: + _HAVE_CERTIFI = False + PROTOCOL_SSLv23 = _SSL.SSLv23_METHOD # Always available OP_NO_SSLv2 = _SSL.OP_NO_SSLv2 @@ -98,14 +107,14 @@ class _sslConn(_SSL.Connection): def _call(self, call, *args, **kwargs): timeout = self.gettimeout() if timeout: - start = time.monotonic() + start = _time.monotonic() while True: try: return call(*args, **kwargs) except _RETRY_ERRORS: self.socket_checker.select( self, True, True, timeout) - if timeout and time.monotonic() - start > timeout: + if timeout and _time.monotonic() - start > timeout: raise _socket.timeout("timed out") continue @@ -272,6 +281,44 @@ class SSLContext(object): self._ctx.load_verify_locations(cafile, capath) self._callback_data.trusted_ca_certs = _load_trusted_ca_certs(cafile) + def _load_certifi(self): + """Attempt to load CA certs from certifi.""" + if _HAVE_CERTIFI: + self.load_verify_locations(certifi.where()) + else: + raise _ConfigurationError( + "tlsAllowInvalidCertificates is False but no system " + "CA certificates could be loaded. Please install the " + "certifi package, or provide a path to a CA file using " + "the tlsCAFile option") + + def _load_wincerts(self, store): + """Attempt to load CA certs from Windows trust store.""" + cert_store = self._ctx.get_cert_store() + oid = _stdlibssl.Purpose.SERVER_AUTH.oid + for cert, encoding, trust in _stdlibssl.enum_certificates(store): + if encoding == "x509_asn": + if trust is True or oid in trust: + cert_store.add_cert( + _crypto.X509.from_cryptography( + _load_der_x509_certificate(cert))) + + def load_default_certs(self): + """A PyOpenSSL version of load_default_certs from CPython.""" + # PyOpenSSL is incapable of loading CA certs from Windows, and mostly + # incapable on macOS. + # https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Context.set_default_verify_paths + if _sys.platform == "win32": + try: + for storename in ('CA', 'ROOT'): + self._load_wincerts(storename) + except PermissionError: + # Fall back to certifi + self._load_certifi() + elif _sys.platform == "darwin": + self._load_certifi() + self._ctx.set_default_verify_paths() + def set_default_verify_paths(self): """Specify that the platform provided CA certificates are to be used for verification purposes.""" diff --git a/pymongo/ssl_support.py b/pymongo/ssl_support.py index d8e55a1f7..ca6ee8575 100644 --- a/pymongo/ssl_support.py +++ b/pymongo/ssl_support.py @@ -14,9 +14,7 @@ """Support for SSL in PyMongo.""" -import atexit import sys -import threading from pymongo.errors import ConfigurationError @@ -30,22 +28,6 @@ except ImportError: except ImportError: HAVE_SSL = False -HAVE_CERTIFI = False -try: - import certifi - HAVE_CERTIFI = True -except ImportError: - pass - -HAVE_WINCERTSTORE = False -try: - from wincertstore import CertFile - HAVE_WINCERTSTORE = True -except ImportError: - pass - -_WINCERTSLOCK = threading.Lock() -_WINCERTS = None if HAVE_SSL: # Note: The validate* functions below deal with users passing @@ -82,17 +64,6 @@ if HAVE_SSL: return CERT_NONE return CERT_REQUIRED - def _load_wincerts(): - """Set _WINCERTS to an instance of wincertstore.Certfile.""" - global _WINCERTS - - certfile = CertFile() - certfile.addstore("CA") - certfile.addstore("ROOT") - atexit.register(certfile.close) - - _WINCERTS = certfile - def get_ssl_context(*args): """Create and return an SSLContext object.""" (certfile, @@ -138,30 +109,7 @@ if HAVE_SSL: if ca_certs is not None: ctx.load_verify_locations(ca_certs) elif cert_reqs != CERT_NONE: - # CPython ssl module only, doesn't exist in PyOpenSSL - if hasattr(ctx, "load_default_certs"): - ctx.load_default_certs() - # Always useless on Windows. - elif (sys.platform != "win32" and - hasattr(ctx, "set_default_verify_paths")): - ctx.set_default_verify_paths() - # This is needed with PyOpenSSL on Windows - # https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Context.set_default_verify_paths - elif sys.platform == "win32" and HAVE_WINCERTSTORE: - with _WINCERTSLOCK: - if _WINCERTS is None: - _load_wincerts() - ctx.load_verify_locations(_WINCERTS.name) - # This is necessary with PyOpenSSL on macOS when homebrew isn't - # installed. - # https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Context.set_default_verify_paths - elif HAVE_CERTIFI: - ctx.load_verify_locations(certifi.where()) - else: - raise ConfigurationError( - "`ssl_cert_reqs` is not ssl.CERT_NONE and no system " - "CA certificates could be loaded. `ssl_ca_certs` is " - "required.") + ctx.load_default_certs() ctx.verify_mode = verify_mode return ctx else: diff --git a/setup.py b/setup.py index 2c2b9e53c..6ff561a09 100755 --- a/setup.py +++ b/setup.py @@ -275,6 +275,11 @@ ext_modules = [Extension('bson._cbson', # in set_default_verify_paths we should really avoid. # service_identity 18.1.0 introduced support for IP addr matching. pyopenssl_reqs = ["pyopenssl>=17.2.0", "requests<3.0.0", "service_identity>=18.1.0"] +if sys.platform in ('win32', 'darwin'): + # Fallback to certifi on Windows if we can't load CA certs from the system + # store and just use certifi on macOS. + # https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Context.set_default_verify_paths + pyopenssl_reqs.append('certifi') extras_require = { 'encryption': ['pymongocrypt<2.0.0'], diff --git a/test/test_ssl.py b/test/test_ssl.py index 024230105..82d99d5d1 100644 --- a/test/test_ssl.py +++ b/test/test_ssl.py @@ -336,25 +336,21 @@ class TestSSL(IntegrationTest): # # --sslPEMKeyFile=/path/to/pymongo/test/certificates/server.pem # --sslCAFile=/path/to/pymongo/test/certificates/ca.pem - - # Python > 2.7.9. If SSLContext doesn't have load_default_certs - # it also doesn't have check_hostname. ctx = get_ssl_context( None, None, None, None, ssl.CERT_NONE, None, False, True) - if hasattr(ctx, 'load_default_certs'): + self.assertFalse(ctx.check_hostname) + ctx = get_ssl_context( + None, None, None, None, ssl.CERT_NONE, None, True, True) + self.assertFalse(ctx.check_hostname) + ctx = get_ssl_context( + None, None, None, None, ssl.CERT_REQUIRED, None, False, True) + self.assertFalse(ctx.check_hostname) + ctx = get_ssl_context( + None, None, None, None, ssl.CERT_REQUIRED, None, True, True) + if _PY37PLUS or _HAVE_PYOPENSSL: + self.assertTrue(ctx.check_hostname) + else: self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context( - None, None, None, None, ssl.CERT_NONE, None, True, True) - self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context( - None, None, None, None, ssl.CERT_REQUIRED, None, False, True) - self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context( - None, None, None, None, ssl.CERT_REQUIRED, None, True, True) - if _PY37PLUS: - self.assertTrue(ctx.check_hostname) - else: - self.assertFalse(ctx.check_hostname) response = self.client.admin.command('ismaster')