summaryrefslogtreecommitdiff
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
parentd1291660908f656447bb9132c92813489342ead4 (diff)
downloadredis-py-f0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85.tar.gz
OCSP Stapling Support (#1873)
-rw-r--r--.github/workflows/integration.yaml1
-rw-r--r--docs/examples.rst3
-rw-r--r--docs/examples/connection_examples.ipynb (renamed from docs/examples/connection_example.ipynb)93
-rw-r--r--docs/examples/ssl_connecton_examples.ipynb277
-rwxr-xr-xredis/client.py6
-rwxr-xr-xredis/connection.py48
-rw-r--r--redis/ocsp.py174
-rw-r--r--setup.py2
-rw-r--r--tasks.py2
-rw-r--r--tests/test_ssl.py72
-rw-r--r--tox.ini4
11 files changed, 571 insertions, 111 deletions
diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml
index 92f48a4..b034428 100644
--- a/.github/workflows/integration.yaml
+++ b/.github/workflows/integration.yaml
@@ -30,6 +30,7 @@ jobs:
run-tests:
runs-on: ubuntu-latest
+ timeout-minutes: 30
strategy:
max-parallel: 15
matrix:
diff --git a/docs/examples.rst b/docs/examples.rst
index 1a82182..7a328af 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -5,4 +5,5 @@ Examples
:maxdepth: 3
:glob:
- examples/connection_example
+ examples/connection_examples
+ examples/ssl_connection_examples
diff --git a/docs/examples/connection_example.ipynb b/docs/examples/connection_examples.ipynb
index af5193e..b0084ff 100644
--- a/docs/examples/connection_example.ipynb
+++ b/docs/examples/connection_examples.ipynb
@@ -8,15 +8,6 @@
]
},
{
- "cell_type": "code",
- "execution_count": 1,
- "metadata": {},
- "outputs": [],
- "source": [
- "import redis"
- ]
- },
- {
"cell_type": "markdown",
"metadata": {},
"source": [
@@ -40,6 +31,8 @@
}
],
"source": [
+ "import redis\n",
+ "\n",
"connection = redis.Redis()\n",
"connection.ping()"
]
@@ -68,8 +61,10 @@
}
],
"source": [
- "decode_connection = redis.Redis(decode_responses=True)\n",
- "connection.ping()"
+ "import redis\n",
+ "\n",
+ "decoded_connection = redis.Redis(decode_responses=True)\n",
+ "decoded_connection.ping()"
]
},
{
@@ -96,6 +91,8 @@
}
],
"source": [
+ "import redis\n",
+ "\n",
"user_connection = redis.Redis(host='localhost', port=6380, username='dvora', password='redis', decode_responses=True)\n",
"user_connection.ping()"
]
@@ -104,78 +101,6 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Connecting to a Redis instance via SSL."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "True"
- ]
- },
- "execution_count": 5,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "ssl_connection = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n",
- "ssl_connection.ping()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Connecting to a Redis instance via SSL, while specifying a self-signed SSL certificate."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "True"
- ]
- },
- "execution_count": 6,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "import os\n",
- "\n",
- "ROOT = os.path.join(os.getcwd(), \"..\", \"..\")\n",
- "CERT_DIR = os.path.abspath(os.path.join(ROOT, \"docker\", \"stunnel\", \"keys\"))\n",
- "ssl_certfile=os.path.join(CERT_DIR, \"server-cert.pem\")\n",
- "ssl_keyfile=os.path.join(CERT_DIR, \"server-key.pem\")\n",
- "ssl_ca_certs=os.path.join(CERT_DIR, \"server-cert.pem\")\n",
- "\n",
- "ssl_cert_conn = redis.Redis(\n",
- " host=\"localhost\",\n",
- " port=6666,\n",
- " ssl=True,\n",
- " ssl_certfile=ssl_certfile,\n",
- " ssl_keyfile=ssl_keyfile,\n",
- " ssl_cert_reqs=\"required\",\n",
- " ssl_ca_certs=ssl_ca_certs,\n",
- ")\n",
- "ssl_cert_conn.ping()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
"## Connecting to Redis instances by specifying a URL scheme.\n",
"Parameters are passed to the following schems, as parameters to the url scheme.\n",
"\n",
@@ -203,7 +128,7 @@
}
],
"source": [
- "url_connection = redis.from_url(\"rediss://localhost:6666?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\")\n",
+ "url_connection = redis.from_url(\"redis://localhost:6379?decode_responses=True&health_check_interval=2\")\n",
"\n",
"url_connection.ping()"
]
diff --git a/docs/examples/ssl_connecton_examples.ipynb b/docs/examples/ssl_connecton_examples.ipynb
new file mode 100644
index 0000000..386e4af
--- /dev/null
+++ b/docs/examples/ssl_connecton_examples.ipynb
@@ -0,0 +1,277 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# SSL Connection Examples"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Connecting to a Redis instance via SSL."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import redis\n",
+ "\n",
+ "ssl_connection = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n",
+ "ssl_connection.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Connecting to a Redis instance via a URL string"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import redis\n",
+ "url_connection = redis.from_url(\"redis://localhost:6379?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\")\n",
+ "url_connection.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Connecting to a Redis instance via SSL, while specifying a self-signed SSL certificate."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import os\n",
+ "import redis\n",
+ "\n",
+ "ssl_certfile=\"some-certificate.pem\"\n",
+ "ssl_keyfile=\"some-key.pem\"\n",
+ "ssl_ca_certs=ssl_certfile\n",
+ "\n",
+ "ssl_cert_conn = redis.Redis(\n",
+ " host=\"localhost\",\n",
+ " port=6666,\n",
+ " ssl=True,\n",
+ " ssl_certfile=ssl_certfile,\n",
+ " ssl_keyfile=ssl_keyfile,\n",
+ " ssl_cert_reqs=\"required\",\n",
+ " ssl_ca_certs=ssl_ca_certs,\n",
+ ")\n",
+ "ssl_cert_conn.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Connecting to a Redis instance via SSL, and validate the OCSP status of the certificate\n",
+ "\n",
+ "The redis package is design to be small, meaning extra libraries must be installed, in order to support OCSP stapling. As a result, first install redis via:\n",
+ "\n",
+ "*pip install redis[ocsp]*\n",
+ "\n",
+ "This will install cryptography, requests, and PyOpenSSL, none of which are generally required to use Redis."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "import os\n",
+ "import redis\n",
+ "\n",
+ "ssl_certfile=\"some-certificate.pem\"\n",
+ "ssl_keyfile=\"some-key.pem\"\n",
+ "ssl_ca_certs=ssl_certfile\n",
+ "\n",
+ "ssl_cert_conn = redis.Redis(\n",
+ " host=\"localhost\",\n",
+ " port=6666,\n",
+ " ssl=True,\n",
+ " ssl_certfile=ssl_certfile,\n",
+ " ssl_keyfile=ssl_keyfile,\n",
+ " ssl_cert_reqs=\"required\",\n",
+ " ssl_validate_ocsp=True\n",
+ ")\n",
+ "ssl_cert_conn.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Connect via SSL, validate OCSP-stapled certificates\n",
+ "\n",
+ "The redis package is design to be small, meaning extra libraries must be installed, in order to support OCSP stapling. As a result, first install redis via:\n",
+ "\n",
+ "*pip install redis[ocsp]*\n",
+ "\n",
+ "This will install cryptography, requests, and PyOpenSSL, none of which are generally required to use Redis."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Using a custom SSL context and validating against an expected certificate"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "import redis\n",
+ "import OpenSSL\n",
+ "\n",
+ "ssl_certfile=\"some-certificate.pem\"\n",
+ "ssl_keyfile=\"some-key.pem\"\n",
+ "ssl_ca_certs=ssl_certfile\n",
+ "ssl_expected_certificate = \"expected-ocsp-certificate.pem\"\n",
+ "\n",
+ "# PyOpenSSL is used only for the purpose of validating the ocsp\n",
+ "# stapled response\n",
+ "ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)\n",
+ "ctx.use_certificate_file=ssl_certfile\n",
+ "ctx.use_privatekey_file=ssl_keyfile\n",
+ "expected_certificate = open(ssl_expected_certificate, 'rb').read()\n",
+ "\n",
+ "ssl_cert_conn = redis.Redis(\n",
+ " host=\"localhost\",\n",
+ " port=6666,\n",
+ " ssl=True,\n",
+ " ssl_certfile=ssl_certfile,\n",
+ " ssl_keyfile=ssl_keyfile,\n",
+ " ssl_cert_reqs=\"required\",\n",
+ " ssl_ocsp_context=ctx,\n",
+ " ssl_ocsp_expected_cert=expected_certificate,\n",
+ ")\n",
+ "ssl_cert_conn.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Naive validation of a stapled OCSP certificate"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import redis\n",
+ "import OpenSSL\n",
+ "\n",
+ "ssl_certfile=\"some-certificate.pem\"\n",
+ "ssl_keyfile=\"some-key.pem\"\n",
+ "ssl_ca_certs=ssl_certfile\n",
+ "ssl_expected_certificate = \"expected-ocsp-certificate.pem\"\n",
+ "\n",
+ "# PyOpenSSL is used only for the purpose of validating the ocsp\n",
+ "# stapled response\n",
+ "ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)\n",
+ "ctx.use_certificate_file=ssl_certfile\n",
+ "ctx.use_privatekey_file=ssl_keyfile\n",
+ "\n",
+ "ssl_cert_conn = redis.Redis(\n",
+ " host=\"localhost\",\n",
+ " port=6666,\n",
+ " ssl=True,\n",
+ " ssl_certfile=ssl_certfile,\n",
+ " ssl_keyfile=ssl_keyfile,\n",
+ " ssl_cert_reqs=\"required\",\n",
+ " ssl_validate_ocsp_stapled=True,\n",
+ ")\n",
+ "ssl_cert_conn.ping()"
+ ]
+ }
+ ],
+ "metadata": {
+ "interpreter": {
+ "hash": "d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe"
+ },
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.8.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
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)
diff --git a/setup.py b/setup.py
index 559e521..218fc27 100644
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,6 @@ setup(
],
extras_require={
"hiredis": ["hiredis>=1.0.0"],
- "cryptography": ["cryptography>=36.0.1", "requests>=2.26.0"],
+ "ocsp": ["cryptography>=36.0.1", "pyopenssl==20.0.1", "requests>=2.26.0"],
},
)
diff --git a/tasks.py b/tasks.py
index 313986d..96005ca 100644
--- a/tasks.py
+++ b/tasks.py
@@ -66,7 +66,7 @@ def standalone_tests(c):
with and without hiredis."""
print("Starting Redis tests")
_generate_keys()
- run("tox -e standalone-'{plain,hiredis,cryptography}'")
+ run("tox -e standalone-'{plain,hiredis,ocsp}'")
@task
diff --git a/tests/test_ssl.py b/tests/test_ssl.py
index a2f66b2..0ae7440 100644
--- a/tests/test_ssl.py
+++ b/tests/test_ssl.py
@@ -28,6 +28,9 @@ class TestSSL:
if not os.path.isdir(CERT_DIR):
raise IOError(f"No SSL certificates found. They should be in {CERT_DIR}")
+ SERVER_CERT = os.path.join(CERT_DIR, "server-cert.pem")
+ SERVER_KEY = os.path.join(CERT_DIR, "server-key.pem")
+
def test_ssl_with_invalid_cert(self, request):
ssl_url = request.config.option.redis_ssl_url
sslclient = redis.from_url(ssl_url)
@@ -57,10 +60,10 @@ class TestSSL:
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_certfile=self.SERVER_CERT,
+ ssl_keyfile=self.SERVER_KEY,
ssl_cert_reqs="required",
- ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"),
+ ssl_ca_certs=self.SERVER_CERT,
)
assert r.ping()
@@ -71,10 +74,10 @@ class TestSSL:
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_certfile=self.SERVER_CERT,
+ ssl_keyfile=self.SERVER_KEY,
ssl_cert_reqs="required",
- ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"),
+ ssl_ca_certs=self.SERVER_CERT,
ssl_validate_ocsp=True,
)
return r
@@ -159,3 +162,60 @@ class TestSSL:
with context.wrap_socket(sock, server_hostname=hostname) as wrapped:
ocsp = OCSPVerifier(wrapped, hostname, 443)
assert ocsp.is_valid()
+
+ @skip_if_nocryptography()
+ def test_mock_ocsp_staple(self, request):
+ import OpenSSL
+
+ 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=self.SERVER_CERT,
+ ssl_keyfile=self.SERVER_KEY,
+ ssl_cert_reqs="required",
+ ssl_ca_certs=self.SERVER_CERT,
+ ssl_validate_ocsp=True,
+ ssl_ocsp_context=p, # just needs to not be none
+ )
+
+ with pytest.raises(RedisError):
+ r.ping()
+
+ ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
+ ctx.use_certificate_file(self.SERVER_CERT)
+ ctx.use_privatekey_file(self.SERVER_KEY)
+
+ r = redis.Redis(
+ host=p[0],
+ port=p[1],
+ ssl=True,
+ ssl_certfile=self.SERVER_CERT,
+ ssl_keyfile=self.SERVER_KEY,
+ ssl_cert_reqs="required",
+ ssl_ca_certs=self.SERVER_CERT,
+ ssl_ocsp_context=ctx,
+ ssl_ocsp_expected_cert=open(self.SERVER_KEY, "rb").read(),
+ ssl_validate_ocsp_stapled=True,
+ )
+
+ with pytest.raises(ConnectionError) as e:
+ r.ping()
+ assert "no ocsp response present" in str(e)
+
+ r = redis.Redis(
+ host=p[0],
+ port=p[1],
+ ssl=True,
+ ssl_certfile=self.SERVER_CERT,
+ ssl_keyfile=self.SERVER_KEY,
+ ssl_cert_reqs="required",
+ ssl_ca_certs=self.SERVER_CERT,
+ ssl_validate_ocsp_stapled=True,
+ )
+
+ with pytest.raises(ConnectionError) as e:
+ r.ping()
+ assert "no ocsp response present" in str(e)
diff --git a/tox.ini b/tox.ini
index ac7f01a..abebf00 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,cryptography}-{py36,py37,py38,py39,pypy3},linters,docs
+envlist = {standalone,cluster}-{plain,hiredis,ocsp}-{py36,py37,py38,py39,pypy3},linters,docs
[docker:master]
name = master
@@ -128,7 +128,7 @@ docker =
stunnel
extras =
hiredis: hiredis
- cryptography: cryptography, requests
+ ocsp: cryptography, pyopenssl, requests
setenv =
CLUSTER_URL = "redis://localhost:16379/0"
run_before = {toxinidir}/docker/stunnel/create_certs.sh