summaryrefslogtreecommitdiff
path: root/redis
diff options
context:
space:
mode:
authorChayim <chayim@users.noreply.github.com>2022-01-17 09:14:16 +0200
committerGitHub <noreply@github.com>2022-01-17 09:14:16 +0200
commitf0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85 (patch)
treed193560c0528bb95b7ecc5e0f381c4e47528f3a6 /redis
parentd1291660908f656447bb9132c92813489342ead4 (diff)
downloadredis-py-f0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85.tar.gz
OCSP Stapling Support (#1873)
Diffstat (limited to 'redis')
-rwxr-xr-xredis/client.py6
-rwxr-xr-xredis/connection.py48
-rw-r--r--redis/ocsp.py174
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)