Skip to content

Commit b426d0d

Browse files
authored
OCSP stapling support (#1820)
1 parent f03d008 commit b426d0d

File tree

10 files changed

+310
-11
lines changed

10 files changed

+310
-11
lines changed

redis/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,7 @@ def __init__(
878878
ssl_ca_path=None,
879879
ssl_check_hostname=False,
880880
ssl_password=None,
881+
ssl_validate_ocsp=False,
881882
max_connections=None,
882883
single_connection_client=False,
883884
health_check_interval=0,
@@ -956,6 +957,7 @@ def __init__(
956957
"ssl_check_hostname": ssl_check_hostname,
957958
"ssl_password": ssl_password,
958959
"ssl_ca_path": ssl_ca_path,
960+
"ssl_validate_ocsp": ssl_validate_ocsp,
959961
}
960962
)
961963
connection_pool = ConnectionPool(**kwargs)

redis/connection.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
TimeoutError,
3232
)
3333
from redis.retry import Retry
34-
from redis.utils import HIREDIS_AVAILABLE, str_if_bytes
34+
from redis.utils import CRYPTOGRAPHY_AVAILABLE, HIREDIS_AVAILABLE, str_if_bytes
3535

3636
try:
3737
import ssl
@@ -907,6 +907,7 @@ def __init__(
907907
ssl_check_hostname=False,
908908
ssl_ca_path=None,
909909
ssl_password=None,
910+
ssl_validate_ocsp=False,
910911
**kwargs,
911912
):
912913
"""Constructor
@@ -948,6 +949,7 @@ def __init__(
948949
self.ca_path = ssl_ca_path
949950
self.check_hostname = ssl_check_hostname
950951
self.certificate_password = ssl_password
952+
self.ssl_validate_ocsp = ssl_validate_ocsp
951953

952954
def _connect(self):
953955
"Wrap the socket with SSL support"
@@ -963,7 +965,18 @@ def _connect(self):
963965
)
964966
if self.ca_certs is not None or self.ca_path is not None:
965967
context.load_verify_locations(cafile=self.ca_certs, capath=self.ca_path)
966-
return context.wrap_socket(sock, server_hostname=self.host)
968+
sslsock = context.wrap_socket(sock, server_hostname=self.host)
969+
if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False:
970+
raise RedisError("cryptography is not installed.")
971+
elif self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE:
972+
from .ocsp import OCSPVerifier
973+
974+
o = OCSPVerifier(sslsock, self.host, self.port, self.ca_certs)
975+
if o.is_valid():
976+
return sslsock
977+
else:
978+
raise ConnectionError("ocsp validation error")
979+
return sslsock
967980

968981

969982
class UnixDomainSocketConnection(Connection):

redis/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ class AuthenticationError(ConnectionError):
1717
pass
1818

1919

20+
class AuthorizationError(ConnectionError):
21+
pass
22+
23+
2024
class BusyLoadingError(ConnectionError):
2125
pass
2226

redis/ocsp.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import base64
2+
import ssl
3+
from urllib.parse import urljoin, urlparse
4+
5+
import cryptography.hazmat.primitives.hashes
6+
import requests
7+
from cryptography import hazmat, x509
8+
from cryptography.hazmat import backends
9+
from cryptography.x509 import ocsp
10+
11+
from redis.exceptions import AuthorizationError, ConnectionError
12+
13+
14+
class OCSPVerifier:
15+
"""A class to verify ssl sockets for RFC6960/RFC6961.
16+
17+
@see https://datatracker.ietf.org/doc/html/rfc6960
18+
@see https://datatracker.ietf.org/doc/html/rfc6961
19+
"""
20+
21+
def __init__(self, sock, host, port, ca_certs=None):
22+
self.SOCK = sock
23+
self.HOST = host
24+
self.PORT = port
25+
self.CA_CERTS = ca_certs
26+
27+
def _bin2ascii(self, der):
28+
"""Convert SSL certificates in a binary (DER) format to ASCII PEM."""
29+
30+
pem = ssl.DER_cert_to_PEM_cert(der)
31+
cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend())
32+
return cert
33+
34+
def components_from_socket(self):
35+
"""This function returns the certificate, primary issuer, and primary ocsp server
36+
in the chain for a socket already wrapped with ssl.
37+
"""
38+
39+
# convert the binary certifcate to text
40+
der = self.SOCK.getpeercert(True)
41+
if der is False:
42+
raise ConnectionError("no certificate found for ssl peer")
43+
cert = self._bin2ascii(der)
44+
return self._certificate_components(cert)
45+
46+
def _certificate_components(self, cert):
47+
"""Given an SSL certificate, retract the useful components for
48+
validating the certificate status with an OCSP server.
49+
50+
Args:
51+
cert ([bytes]): A PEM encoded ssl certificate
52+
"""
53+
54+
try:
55+
aia = cert.extensions.get_extension_for_oid(
56+
x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS
57+
).value
58+
except cryptography.x509.extensions.ExtensionNotFound:
59+
raise ConnectionError("No AIA information present in ssl certificate")
60+
61+
# fetch certificate issuers
62+
issuers = [
63+
i
64+
for i in aia
65+
if i.access_method == x509.oid.AuthorityInformationAccessOID.CA_ISSUERS
66+
]
67+
try:
68+
issuer = issuers[0].access_location.value
69+
except IndexError:
70+
raise ConnectionError("no issuers in certificate")
71+
72+
# now, the series of ocsp server entries
73+
ocsps = [
74+
i
75+
for i in aia
76+
if i.access_method == x509.oid.AuthorityInformationAccessOID.OCSP
77+
]
78+
79+
try:
80+
ocsp = ocsps[0].access_location.value
81+
except IndexError:
82+
raise ConnectionError("no ocsp servers in certificate")
83+
84+
return cert, issuer, ocsp
85+
86+
def components_from_direct_connection(self):
87+
"""Return the certificate, primary issuer, and primary ocsp server
88+
from the host defined by the socket. This is useful in cases where
89+
different certificates are occasionally presented.
90+
"""
91+
92+
pem = ssl.get_server_certificate((self.HOST, self.PORT), ca_certs=self.CA_CERTS)
93+
cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend())
94+
return self._certificate_components(cert)
95+
96+
def build_certificate_url(self, server, cert, issuer_cert):
97+
"""Return the complete url to the ocsp"""
98+
orb = ocsp.OCSPRequestBuilder()
99+
100+
# add_certificate returns an initialized OCSPRequestBuilder
101+
orb = orb.add_certificate(
102+
cert, issuer_cert, cryptography.hazmat.primitives.hashes.SHA256()
103+
)
104+
request = orb.build()
105+
106+
path = base64.b64encode(
107+
request.public_bytes(hazmat.primitives.serialization.Encoding.DER)
108+
)
109+
url = urljoin(server, path.decode("ascii"))
110+
return url
111+
112+
def check_certificate(self, server, cert, issuer_url):
113+
"""Checks the validitity of an ocsp server for an issuer"""
114+
115+
r = requests.get(issuer_url)
116+
if not r.ok:
117+
raise ConnectionError("failed to fetch issuer certificate")
118+
der = r.content
119+
issuer_cert = self._bin2ascii(der)
120+
121+
ocsp_url = self.build_certificate_url(server, cert, issuer_cert)
122+
123+
# HTTP 1.1 mandates the addition of the Host header in ocsp responses
124+
header = {
125+
"Host": urlparse(ocsp_url).netloc,
126+
"Content-Type": "application/ocsp-request",
127+
}
128+
r = requests.get(ocsp_url, headers=header)
129+
if not r.ok:
130+
raise ConnectionError("failed to fetch ocsp certificate")
131+
132+
ocsp_response = ocsp.load_der_ocsp_response(r.content)
133+
if ocsp_response.response_status == ocsp.OCSPResponseStatus.UNAUTHORIZED:
134+
raise AuthorizationError(
135+
"you are not authorized to view this ocsp certificate"
136+
)
137+
if ocsp_response.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL:
138+
if ocsp_response.certificate_status == ocsp.OCSPCertStatus.REVOKED:
139+
return False
140+
else:
141+
return True
142+
else:
143+
return False
144+
145+
def is_valid(self):
146+
"""Returns the validity of the certificate wrapping our socket.
147+
This first retrieves for validate the certificate, issuer_url,
148+
and ocsp_server for certificate validate. Then retrieves the
149+
issuer certificate from the issuer_url, and finally checks
150+
the valididy of OCSP revocation status.
151+
"""
152+
153+
# validate the certificate
154+
try:
155+
cert, issuer_url, ocsp_server = self.components_from_socket()
156+
return self.check_certificate(ocsp_server, cert, issuer_url)
157+
except AuthorizationError:
158+
cert, issuer_url, ocsp_server = self.components_from_direct_connection()
159+
return self.check_certificate(ocsp_server, cert, issuer_url)

redis/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
except ImportError:
88
HIREDIS_AVAILABLE = False
99

10+
try:
11+
import cryptography # noqa
12+
13+
CRYPTOGRAPHY_AVAILABLE = True
14+
except ImportError:
15+
CRYPTOGRAPHY_AVAILABLE = False
16+
1017

1118
def from_url(url, **kwargs):
1219
"""

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@
4848
],
4949
extras_require={
5050
"hiredis": ["hiredis>=1.0.0"],
51+
"cryptography": ["cryptography>=36.0.1", "requests>=2.26.0"],
5152
},
5253
)

tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def standalone_tests(c):
6565
with and without hiredis."""
6666
print("Starting Redis tests")
6767
_generate_keys()
68-
run("tox -e standalone-'{plain,hiredis}'")
68+
run("tox -e standalone-'{plain,hiredis,cryptography}'")
6969

7070

7171
@task

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,24 @@ def skip_ifnot_redis_enterprise():
170170
return pytest.mark.skipif(check, reason="Not running in redis enterprise")
171171

172172

173+
def skip_if_nocryptography():
174+
try:
175+
import cryptography # noqa
176+
177+
return pytest.mark.skipif(False, reason="Cryptography dependency found")
178+
except ImportError:
179+
return pytest.mark.skipif(True, reason="No cryptography dependency")
180+
181+
182+
def skip_if_cryptography():
183+
try:
184+
import cryptography # noqa
185+
186+
return pytest.mark.skipif(True, reason="Cryptography dependency found")
187+
except ImportError:
188+
return pytest.mark.skipif(False, reason="No cryptography dependency")
189+
190+
173191
def _get_client(
174192
cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs
175193
):

0 commit comments

Comments
 (0)