From f0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 17 Jan 2022 09:14:16 +0200 Subject: OCSP Stapling Support (#1873) --- .github/workflows/integration.yaml | 1 + docs/examples.rst | 3 +- docs/examples/connection_example.ipynb | 254 -------------------------- docs/examples/connection_examples.ipynb | 179 +++++++++++++++++++ docs/examples/ssl_connecton_examples.ipynb | 277 +++++++++++++++++++++++++++++ redis/client.py | 6 + redis/connection.py | 48 ++++- redis/ocsp.py | 174 ++++++++++++++++-- setup.py | 2 +- tasks.py | 2 +- tests/test_ssl.py | 72 +++++++- tox.ini | 4 +- 12 files changed, 741 insertions(+), 281 deletions(-) delete mode 100644 docs/examples/connection_example.ipynb create mode 100644 docs/examples/connection_examples.ipynb create mode 100644 docs/examples/ssl_connecton_examples.ipynb 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_example.ipynb deleted file mode 100644 index af5193e..0000000 --- a/docs/examples/connection_example.ipynb +++ /dev/null @@ -1,254 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Connection Examples" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import redis" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Connecting to a default Redis instance, running locally." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "connection = redis.Redis()\n", - "connection.ping()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### by default Redis return binary responses, to decode them use decode_responses=True" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "decode_connection = redis.Redis(decode_responses=True)\n", - "connection.ping()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Connecting to a redis instance, specifying a host and port with credentials." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "user_connection = redis.Redis(host='localhost', port=6380, username='dvora', password='redis', decode_responses=True)\n", - "user_connection.ping()" - ] - }, - { - "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", - "Three URL schemes are supported:\n", - "\n", - "- `redis://` creates a TCP socket connection. \n", - "- `rediss://` creates a SSL wrapped TCP socket connection. \n", - "- ``unix://``: creates a Unix Domain Socket connection.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "url_connection = redis.from_url(\"rediss://localhost:6666?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\")\n", - "\n", - "url_connection.ping()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Connecting to a Sentinel instance" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from redis.sentinel import Sentinel\n", - "sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\n", - "sentinel.discover_master(\"redis-py-test\")" - ] - } - ], - "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/docs/examples/connection_examples.ipynb b/docs/examples/connection_examples.ipynb new file mode 100644 index 0000000..b0084ff --- /dev/null +++ b/docs/examples/connection_examples.ipynb @@ -0,0 +1,179 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Connection Examples" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a default Redis instance, running locally." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import redis\n", + "\n", + "connection = redis.Redis()\n", + "connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### by default Redis return binary responses, to decode them use decode_responses=True" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import redis\n", + "\n", + "decoded_connection = redis.Redis(decode_responses=True)\n", + "decoded_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a redis instance, specifying a host and port with credentials." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import redis\n", + "\n", + "user_connection = redis.Redis(host='localhost', port=6380, username='dvora', password='redis', decode_responses=True)\n", + "user_connection.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", + "Three URL schemes are supported:\n", + "\n", + "- `redis://` creates a TCP socket connection. \n", + "- `rediss://` creates a SSL wrapped TCP socket connection. \n", + "- ``unix://``: creates a Unix Domain Socket connection.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "url_connection = redis.from_url(\"redis://localhost:6379?decode_responses=True&health_check_interval=2\")\n", + "\n", + "url_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Sentinel instance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from redis.sentinel import Sentinel\n", + "sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\n", + "sentinel.discover_master(\"redis-py-test\")" + ] + } + ], + "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/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 -- cgit v1.2.1