From b426d0d41cc28f1b0f6ec7092cfb819ce00a6e16 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 26 Dec 2021 15:02:43 +0200 Subject: OCSP stapling support (#1820) --- redis/client.py | 2 + redis/connection.py | 17 +++++- redis/exceptions.py | 4 ++ redis/ocsp.py | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++ redis/utils.py | 7 +++ setup.py | 1 + tasks.py | 2 +- tests/conftest.py | 18 ++++++ tests/test_ssl.py | 102 ++++++++++++++++++++++++++++++++- tox.ini | 9 +-- 10 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 redis/ocsp.py diff --git a/redis/client.py b/redis/client.py index 5116482..0984a7c 100755 --- a/redis/client.py +++ b/redis/client.py @@ -878,6 +878,7 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): ssl_ca_path=None, ssl_check_hostname=False, ssl_password=None, + ssl_validate_ocsp=False, max_connections=None, single_connection_client=False, health_check_interval=0, @@ -956,6 +957,7 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): "ssl_check_hostname": ssl_check_hostname, "ssl_password": ssl_password, "ssl_ca_path": ssl_ca_path, + "ssl_validate_ocsp": ssl_validate_ocsp, } ) connection_pool = ConnectionPool(**kwargs) diff --git a/redis/connection.py b/redis/connection.py index a349a0f..bde74b1 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -31,7 +31,7 @@ from redis.exceptions import ( TimeoutError, ) from redis.retry import Retry -from redis.utils import HIREDIS_AVAILABLE, str_if_bytes +from redis.utils import CRYPTOGRAPHY_AVAILABLE, HIREDIS_AVAILABLE, str_if_bytes try: import ssl @@ -907,6 +907,7 @@ class SSLConnection(Connection): ssl_check_hostname=False, ssl_ca_path=None, ssl_password=None, + ssl_validate_ocsp=False, **kwargs, ): """Constructor @@ -948,6 +949,7 @@ class SSLConnection(Connection): self.ca_path = ssl_ca_path self.check_hostname = ssl_check_hostname self.certificate_password = ssl_password + self.ssl_validate_ocsp = ssl_validate_ocsp def _connect(self): "Wrap the socket with SSL support" @@ -963,7 +965,18 @@ class SSLConnection(Connection): ) if self.ca_certs is not None or self.ca_path is not None: context.load_verify_locations(cafile=self.ca_certs, capath=self.ca_path) - return context.wrap_socket(sock, server_hostname=self.host) + sslsock = context.wrap_socket(sock, server_hostname=self.host) + if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False: + raise RedisError("cryptography is not installed.") + elif self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE: + from .ocsp import OCSPVerifier + + o = OCSPVerifier(sslsock, self.host, self.port, self.ca_certs) + if o.is_valid(): + return sslsock + else: + raise ConnectionError("ocsp validation error") + return sslsock class UnixDomainSocketConnection(Connection): diff --git a/redis/exceptions.py b/redis/exceptions.py index e37cad3..d18b354 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -17,6 +17,10 @@ class AuthenticationError(ConnectionError): pass +class AuthorizationError(ConnectionError): + pass + + class BusyLoadingError(ConnectionError): pass diff --git a/redis/ocsp.py b/redis/ocsp.py new file mode 100644 index 0000000..49aaddf --- /dev/null +++ b/redis/ocsp.py @@ -0,0 +1,159 @@ +import base64 +import ssl +from urllib.parse import urljoin, urlparse + +import cryptography.hazmat.primitives.hashes +import requests +from cryptography import hazmat, x509 +from cryptography.hazmat import backends +from cryptography.x509 import ocsp + +from redis.exceptions import AuthorizationError, ConnectionError + + +class OCSPVerifier: + """A class to verify ssl sockets for RFC6960/RFC6961. + + @see https://datatracker.ietf.org/doc/html/rfc6960 + @see https://datatracker.ietf.org/doc/html/rfc6961 + """ + + def __init__(self, sock, host, port, ca_certs=None): + self.SOCK = sock + self.HOST = host + self.PORT = port + self.CA_CERTS = ca_certs + + def _bin2ascii(self, der): + """Convert SSL certificates in a binary (DER) format to ASCII PEM.""" + + pem = ssl.DER_cert_to_PEM_cert(der) + cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend()) + return cert + + def components_from_socket(self): + """This function returns the certificate, primary issuer, and primary ocsp server + in the chain for a socket already wrapped with ssl. + """ + + # convert the binary certifcate to text + der = self.SOCK.getpeercert(True) + if der is False: + raise ConnectionError("no certificate found for ssl peer") + cert = self._bin2ascii(der) + return self._certificate_components(cert) + + def _certificate_components(self, cert): + """Given an SSL certificate, retract the useful components for + validating the certificate status with an OCSP server. + + Args: + cert ([bytes]): A PEM encoded ssl certificate + """ + + try: + aia = cert.extensions.get_extension_for_oid( + x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS + ).value + except cryptography.x509.extensions.ExtensionNotFound: + raise ConnectionError("No AIA information present in ssl certificate") + + # fetch certificate issuers + issuers = [ + i + for i in aia + if i.access_method == x509.oid.AuthorityInformationAccessOID.CA_ISSUERS + ] + try: + issuer = issuers[0].access_location.value + except IndexError: + raise ConnectionError("no issuers in certificate") + + # now, the series of ocsp server entries + ocsps = [ + i + for i in aia + if i.access_method == x509.oid.AuthorityInformationAccessOID.OCSP + ] + + try: + ocsp = ocsps[0].access_location.value + except IndexError: + raise ConnectionError("no ocsp servers in certificate") + + return cert, issuer, ocsp + + def components_from_direct_connection(self): + """Return the certificate, primary issuer, and primary ocsp server + from the host defined by the socket. This is useful in cases where + different certificates are occasionally presented. + """ + + pem = ssl.get_server_certificate((self.HOST, self.PORT), ca_certs=self.CA_CERTS) + cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend()) + return self._certificate_components(cert) + + def build_certificate_url(self, server, cert, issuer_cert): + """Return the complete url to the ocsp""" + orb = ocsp.OCSPRequestBuilder() + + # add_certificate returns an initialized OCSPRequestBuilder + orb = orb.add_certificate( + cert, issuer_cert, cryptography.hazmat.primitives.hashes.SHA256() + ) + request = orb.build() + + path = base64.b64encode( + request.public_bytes(hazmat.primitives.serialization.Encoding.DER) + ) + url = urljoin(server, path.decode("ascii")) + return url + + def check_certificate(self, server, cert, issuer_url): + """Checks the validitity of an ocsp server for an issuer""" + + r = requests.get(issuer_url) + if not r.ok: + raise ConnectionError("failed to fetch issuer certificate") + der = r.content + issuer_cert = self._bin2ascii(der) + + ocsp_url = self.build_certificate_url(server, cert, issuer_cert) + + # HTTP 1.1 mandates the addition of the Host header in ocsp responses + header = { + "Host": urlparse(ocsp_url).netloc, + "Content-Type": "application/ocsp-request", + } + r = requests.get(ocsp_url, headers=header) + if not r.ok: + raise ConnectionError("failed to fetch ocsp certificate") + + ocsp_response = ocsp.load_der_ocsp_response(r.content) + if ocsp_response.response_status == ocsp.OCSPResponseStatus.UNAUTHORIZED: + raise AuthorizationError( + "you are not authorized to view this ocsp certificate" + ) + if ocsp_response.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL: + if ocsp_response.certificate_status == ocsp.OCSPCertStatus.REVOKED: + return False + else: + return True + else: + return False + + def is_valid(self): + """Returns the validity of the certificate wrapping our socket. + This first retrieves for validate the certificate, issuer_url, + and ocsp_server for certificate validate. Then retrieves the + issuer certificate from the issuer_url, and finally checks + the valididy of OCSP revocation status. + """ + + # validate the certificate + try: + cert, issuer_url, ocsp_server = self.components_from_socket() + return self.check_certificate(ocsp_server, cert, issuer_url) + except AuthorizationError: + cert, issuer_url, ocsp_server = self.components_from_direct_connection() + return self.check_certificate(ocsp_server, cert, issuer_url) diff --git a/redis/utils.py b/redis/utils.py index 50961cb..56fec49 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -7,6 +7,13 @@ try: except ImportError: HIREDIS_AVAILABLE = False +try: + import cryptography # noqa + + CRYPTOGRAPHY_AVAILABLE = True +except ImportError: + CRYPTOGRAPHY_AVAILABLE = False + def from_url(url, **kwargs): """ diff --git a/setup.py b/setup.py index 524ea84..83b4a44 100644 --- a/setup.py +++ b/setup.py @@ -48,5 +48,6 @@ setup( ], extras_require={ "hiredis": ["hiredis>=1.0.0"], + "cryptography": ["cryptography>=36.0.1", "requests>=2.26.0"], }, ) diff --git a/tasks.py b/tasks.py index 98ed483..0fbb5e8 100644 --- a/tasks.py +++ b/tasks.py @@ -65,7 +65,7 @@ def standalone_tests(c): with and without hiredis.""" print("Starting Redis tests") _generate_keys() - run("tox -e standalone-'{plain,hiredis}'") + run("tox -e standalone-'{plain,hiredis,cryptography}'") @task diff --git a/tests/conftest.py b/tests/conftest.py index 0149166..505a6e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -170,6 +170,24 @@ def skip_ifnot_redis_enterprise(): return pytest.mark.skipif(check, reason="Not running in redis enterprise") +def skip_if_nocryptography(): + try: + import cryptography # noqa + + return pytest.mark.skipif(False, reason="Cryptography dependency found") + except ImportError: + return pytest.mark.skipif(True, reason="No cryptography dependency") + + +def skip_if_cryptography(): + try: + import cryptography # noqa + + return pytest.mark.skipif(True, reason="Cryptography dependency found") + except ImportError: + return pytest.mark.skipif(False, reason="No cryptography dependency") + + def _get_client( cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs ): diff --git a/tests/test_ssl.py b/tests/test_ssl.py index 70f9e58..a2f66b2 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -1,10 +1,14 @@ import os +import socket +import ssl from urllib.parse import urlparse import pytest import redis -from redis.exceptions import ConnectionError +from redis.exceptions import ConnectionError, RedisError + +from .conftest import skip_if_cryptography, skip_if_nocryptography @pytest.mark.ssl @@ -59,3 +63,99 @@ class TestSSL: ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), ) assert r.ping() + + def _create_oscp_conn(self, request): + ssl_url = request.config.option.redis_ssl_url + p = urlparse(ssl_url)[1].split(":") + r = redis.Redis( + host=p[0], + port=p[1], + ssl=True, + ssl_certfile=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_keyfile=os.path.join(self.CERT_DIR, "server-key.pem"), + ssl_cert_reqs="required", + ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_validate_ocsp=True, + ) + return r + + @skip_if_cryptography() + def test_ssl_ocsp_called(self, request): + r = self._create_oscp_conn(request) + with pytest.raises(RedisError) as e: + assert r.ping() + assert "cryptography not installed" in str(e) + + @skip_if_nocryptography() + def test_ssl_ocsp_called_withcrypto(self, request): + r = self._create_oscp_conn(request) + with pytest.raises(ConnectionError) as e: + assert r.ping() + assert "No AIA information present in ssl certificate" in str(e) + + # rediss://, url based + ssl_url = request.config.option.redis_ssl_url + sslclient = redis.from_url(ssl_url) + with pytest.raises(ConnectionError) as e: + sslclient.ping() + assert "No AIA information present in ssl certificate" in str(e) + + @skip_if_nocryptography() + def test_valid_ocsp_cert_http(self): + from redis.ocsp import OCSPVerifier + + hostnames = ["github.com", "aws.amazon.com", "ynet.co.il", "microsoft.com"] + for hostname in hostnames: + context = ssl.create_default_context() + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() + + @skip_if_nocryptography() + def test_revoked_ocsp_certificate(self): + from redis.ocsp import OCSPVerifier + + context = ssl.create_default_context() + hostname = "revoked.badssl.com" + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() is False + + @skip_if_nocryptography() + def test_unauthorized_ocsp(self): + from redis.ocsp import OCSPVerifier + + context = ssl.create_default_context() + hostname = "stackoverflow.com" + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + with pytest.raises(ConnectionError): + ocsp.is_valid() + + @skip_if_nocryptography() + def test_ocsp_not_present_in_response(self): + from redis.ocsp import OCSPVerifier + + context = ssl.create_default_context() + hostname = "google.co.il" + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() is False + + @skip_if_nocryptography() + def test_unauthorized_then_direct(self): + from redis.ocsp import OCSPVerifier + + # these certificates on the socket end return unauthorized + # then the second call succeeds + hostnames = ["wikipedia.org", "squarespace.com"] + for hostname in hostnames: + context = ssl.create_default_context() + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() diff --git a/tox.ini b/tox.ini index bceab0b..0da66ed 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ markers = [tox] minversion = 3.2.0 requires = tox-docker -envlist = {standalone,cluster}-{plain,hiredis}-{py36,py37,py38,py39,pypy3},linters,docs +envlist = {standalone,cluster}-{plain,hiredis,cryptography}-{py36,py37,py38,py39,pypy3},linters,docs [docker:master] name = master @@ -118,6 +118,7 @@ docker = stunnel extras = hiredis: hiredis + cryptography: cryptography, requests setenv = CLUSTER_URL = "redis://localhost:16379/0" run_before = {toxinidir}/docker/stunnel/create_certs.sh @@ -145,12 +146,6 @@ commands = skipsdist = true skip_install = true -[testenv:pypy3-plain] -basepython = pypy3 - -[testenv:pypy3-hiredis] -basepython = pypy3 - [testenv:docs] deps_files = docs/requirements.txt docker = -- cgit v1.2.1