diff options
author | Chayim <chayim@users.noreply.github.com> | 2022-01-17 09:14:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-01-17 09:14:16 +0200 |
commit | f0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85 (patch) | |
tree | d193560c0528bb95b7ecc5e0f381c4e47528f3a6 /redis | |
parent | d1291660908f656447bb9132c92813489342ead4 (diff) | |
download | redis-py-f0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85.tar.gz |
OCSP Stapling Support (#1873)
Diffstat (limited to 'redis')
-rwxr-xr-x | redis/client.py | 6 | ||||
-rwxr-xr-x | redis/connection.py | 48 | ||||
-rw-r--r-- | redis/ocsp.py | 174 |
3 files changed, 212 insertions, 16 deletions
diff --git a/redis/client.py b/redis/client.py index 2832d2c..612f911 100755 --- a/redis/client.py +++ b/redis/client.py @@ -880,6 +880,9 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): ssl_check_hostname=False, ssl_password=None, ssl_validate_ocsp=False, + ssl_validate_ocsp_stapled=False, + ssl_ocsp_context=None, + ssl_ocsp_expected_cert=None, max_connections=None, single_connection_client=False, health_check_interval=0, @@ -958,7 +961,10 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): "ssl_check_hostname": ssl_check_hostname, "ssl_password": ssl_password, "ssl_ca_path": ssl_ca_path, + "ssl_validate_ocsp_stapled": ssl_validate_ocsp_stapled, "ssl_validate_ocsp": ssl_validate_ocsp, + "ssl_ocsp_context": ssl_ocsp_context, + "ssl_ocsp_expected_cert": ssl_ocsp_expected_cert, } ) connection_pool = ConnectionPool(**kwargs) diff --git a/redis/connection.py b/redis/connection.py index 4178f67..5fdac54 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -908,6 +908,9 @@ class SSLConnection(Connection): ssl_ca_path=None, ssl_password=None, ssl_validate_ocsp=False, + ssl_validate_ocsp_stapled=False, + ssl_ocsp_context=None, + ssl_ocsp_expected_cert=None, **kwargs, ): """Constructor @@ -921,6 +924,11 @@ class SSLConnection(Connection): ssl_ca_path: The path to a directory containing several CA certificates in PEM format. Defaults to None. ssl_password: Password for unlocking an encrypted private key. Defaults to None. + ssl_validate_ocsp: If set, perform a full ocsp validation (i.e not a stapled verification) + ssl_validate_ocsp_stapled: If set, perform a validation on a stapled ocsp response + ssl_ocsp_context: A fully initialized OpenSSL.SSL.Context object to be used in verifying the ssl_ocsp_expected_cert + ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service. + Raises: RedisError """ # noqa @@ -950,6 +958,9 @@ class SSLConnection(Connection): self.check_hostname = ssl_check_hostname self.certificate_password = ssl_password self.ssl_validate_ocsp = ssl_validate_ocsp + self.ssl_validate_ocsp_stapled = ssl_validate_ocsp_stapled + self.ssl_ocsp_context = ssl_ocsp_context + self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert def _connect(self): "Wrap the socket with SSL support" @@ -968,7 +979,42 @@ class SSLConnection(Connection): 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: + + if self.ssl_validate_ocsp_stapled and self.ssl_validate_ocsp: + raise RedisError( + "Either an OCSP staple or pure OCSP connection must be validated " + "- not both." + ) + + # validation for the stapled case + if self.ssl_validate_ocsp_stapled: + import OpenSSL + + from .ocsp import ocsp_staple_verifier + + # if a context is provided use it - otherwise, a basic context + if self.ssl_ocsp_context is None: + staple_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) + staple_ctx.use_certificate_file(self.certfile) + staple_ctx.use_privatekey_file(self.keyfile) + else: + staple_ctx = self.ssl_ocsp_context + + staple_ctx.set_ocsp_client_callback( + ocsp_staple_verifier, + self.ssl_ocsp_expected_cert, + ) + + # need another socket + con = OpenSSL.SSL.Connection(staple_ctx, socket.socket()) + con.request_ocsp() + con.connect((self.host, self.port)) + con.do_handshake() + con.shutdown() + return sslsock + + # pure ocsp validation + if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE: from .ocsp import OCSPVerifier o = OCSPVerifier(sslsock, self.host, self.port, self.ca_certs) diff --git a/redis/ocsp.py b/redis/ocsp.py index 49aaddf..666c7dc 100644 --- a/redis/ocsp.py +++ b/redis/ocsp.py @@ -1,18 +1,170 @@ import base64 +import datetime import ssl from urllib.parse import urljoin, urlparse import cryptography.hazmat.primitives.hashes import requests from cryptography import hazmat, x509 +from cryptography.exceptions import InvalidSignature from cryptography.hazmat import backends +from cryptography.hazmat.primitives.asymmetric.dsa import DSAPublicKey +from cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePublicKey +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.hashes import SHA1, Hash +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from cryptography.x509 import ocsp from redis.exceptions import AuthorizationError, ConnectionError +def _verify_response(issuer_cert, ocsp_response): + pubkey = issuer_cert.public_key() + try: + if isinstance(pubkey, RSAPublicKey): + pubkey.verify( + ocsp_response.signature, + ocsp_response.tbs_response_bytes, + PKCS1v15(), + ocsp_response.signature_hash_algorithm, + ) + elif isinstance(pubkey, DSAPublicKey): + pubkey.verify( + ocsp_response.signature, + ocsp_response.tbs_response_bytes, + ocsp_response.signature_hash_algorithm, + ) + elif isinstance(pubkey, EllipticCurvePublicKey): + pubkey.verify( + ocsp_response.signature, + ocsp_response.tbs_response_bytes, + ECDSA(ocsp_response.signature_hash_algorithm), + ) + else: + pubkey.verify(ocsp_response.signature, ocsp_response.tbs_response_bytes) + except InvalidSignature: + raise ConnectionError("failed to valid ocsp response") + + +def _check_certificate(issuer_cert, ocsp_bytes, validate=True): + """A wrapper the return the validity of a known ocsp certificate""" + + ocsp_response = ocsp.load_der_ocsp_response(ocsp_bytes) + + 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.GOOD: + return False + else: + return False + + if ocsp_response.this_update >= datetime.datetime.now(): + raise ConnectionError("ocsp certificate was issued in the future") + + if ( + ocsp_response.next_update + and ocsp_response.next_update < datetime.datetime.now() + ): + raise ConnectionError("ocsp certificate has invalid update - in the past") + + responder_name = ocsp_response.responder_name + issuer_hash = ocsp_response.issuer_key_hash + responder_hash = ocsp_response.responder_key_hash + + cert_to_validate = issuer_cert + if ( + responder_name is not None + and responder_name == issuer_cert.subject + or responder_hash == issuer_hash + ): + cert_to_validate = issuer_cert + else: + certs = ocsp_response.certificates + responder_certs = _get_certificates( + certs, issuer_cert, responder_name, responder_hash + ) + + try: + responder_cert = responder_certs[0] + except IndexError: + raise ConnectionError("no certificates found for the responder") + + ext = responder_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + if ext is None or x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING not in ext.value: + raise ConnectionError("delegate not autorized for ocsp signing") + cert_to_validate = responder_cert + + if validate: + _verify_response(cert_to_validate, ocsp_response) + return True + + +def _get_certificates(certs, issuer_cert, responder_name, responder_hash): + if responder_name is None: + certificates = [ + c + for c in certs + if _get_pubkey_hash(c) == responder_hash and c.issuer == issuer_cert.subject + ] + else: + certificates = [ + c + for c in certs + if c.subject == responder_name and c.issuer == issuer_cert.subject + ] + + return certificates + + +def _get_pubkey_hash(certificate): + pubkey = certificate.public_key() + + # https://stackoverflow.com/a/46309453/600498 + if isinstance(pubkey, RSAPublicKey): + h = pubkey.public_bytes(Encoding.DER, PublicFormat.PKCS1) + elif isinstance(pubkey, EllipticCurvePublicKey): + h = pubkey.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) + else: + h = pubkey.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + + sha1 = Hash(SHA1(), backend=backends.default_backend()) + sha1.update(h) + return sha1.finalize() + + +def ocsp_staple_verifier(con, ocsp_bytes, expected=None): + """An implemention of a function for set_ocsp_client_callback in PyOpenSSL. + + This function validates that the provide ocsp_bytes response is valid, + and matches the expected, stapled responses. + """ + if ocsp_bytes in [b"", None]: + raise ConnectionError("no ocsp response present") + + issuer_cert = None + peer_cert = con.get_peer_certificate().to_cryptography() + for c in con.get_peer_cert_chain(): + cert = c.to_cryptography() + if cert.subject == peer_cert.issuer: + issuer_cert = cert + break + + if issuer_cert is None: + raise ConnectionError("no matching issuer cert found in certificate chain") + + if expected is not None: + e = x509.load_pem_x509_certificate(expected) + if peer_cert != e: + raise ConnectionError("received and expected certificates do not match") + + return _check_certificate(issuer_cert, ocsp_bytes) + + class OCSPVerifier: - """A class to verify ssl sockets for RFC6960/RFC6961. + """A class to verify ssl sockets for RFC6960/RFC6961. This can be used + when using direct validation of OCSP responses and certificate revocations. @see https://datatracker.ietf.org/doc/html/rfc6960 @see https://datatracker.ietf.org/doc/html/rfc6961 @@ -67,7 +219,7 @@ class OCSPVerifier: try: issuer = issuers[0].access_location.value except IndexError: - raise ConnectionError("no issuers in certificate") + issuer = None # now, the series of ocsp server entries ocsps = [ @@ -128,19 +280,7 @@ class OCSPVerifier: 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 + return _check_certificate(issuer_cert, r.content, True) def is_valid(self): """Returns the validity of the certificate wrapping our socket. @@ -153,7 +293,11 @@ class OCSPVerifier: # validate the certificate try: cert, issuer_url, ocsp_server = self.components_from_socket() + if issuer_url is None: + raise ConnectionError("no issuers found in certificate chain") return self.check_certificate(ocsp_server, cert, issuer_url) except AuthorizationError: cert, issuer_url, ocsp_server = self.components_from_direct_connection() + if issuer_url is None: + raise ConnectionError("no issuers found in certificate chain") return self.check_certificate(ocsp_server, cert, issuer_url) |