summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChayim <chayim@users.noreply.github.com>2021-12-26 15:02:43 +0200
committerGitHub <noreply@github.com>2021-12-26 15:02:43 +0200
commitb426d0d41cc28f1b0f6ec7092cfb819ce00a6e16 (patch)
tree2ebb0f286a7d94cad3e6489ca9cd442f593df6a1
parentf03d008ba226c698e266158012b47b348b89b503 (diff)
downloadredis-py-b426d0d41cc28f1b0f6ec7092cfb819ce00a6e16.tar.gz
OCSP stapling support (#1820)
-rwxr-xr-xredis/client.py2
-rwxr-xr-xredis/connection.py17
-rw-r--r--redis/exceptions.py4
-rw-r--r--redis/ocsp.py159
-rw-r--r--redis/utils.py7
-rw-r--r--setup.py1
-rw-r--r--tasks.py2
-rw-r--r--tests/conftest.py18
-rw-r--r--tests/test_ssl.py102
-rw-r--r--tox.ini9
10 files changed, 310 insertions, 11 deletions
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 =