Repository: redis/redis-py

Issue:
Enhancement Request: Support for encrypted SSL private keys
redis-py has long supported using SSL client certificates by specifying the `ssl_certfile` and `ssl_keyfile` arguments. Unfortunately, there doesn't seem to be any way to make this work if the private key is password-encrypted. The underlying `ssl` module supports this natively (using the `password` argument to [`SSLContext.load_cert_chain`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.load_cert_chain)), so supporting it should be a relatively simple matter of adding an optional `ssl_password` argument.

Thanks for taking the time to read this!


Discussion:

Resolving patch:
diff --git a/redis/client.py b/redis/client.py
--- a/redis/client.py
+++ b/redis/client.py
@@ -873,7 +873,9 @@ def __init__(
         ssl_certfile=None,
         ssl_cert_reqs="required",
         ssl_ca_certs=None,
+        ssl_ca_path=None,
         ssl_check_hostname=False,
+        ssl_password=None,
         max_connections=None,
         single_connection_client=False,
         health_check_interval=0,
@@ -947,6 +949,7 @@ def __init__(
                             "ssl_cert_reqs": ssl_cert_reqs,
                             "ssl_ca_certs": ssl_ca_certs,
                             "ssl_check_hostname": ssl_check_hostname,
+                            "ssl_password": ssl_password,
                         }
                     )
             connection_pool = ConnectionPool(**kwargs)
diff --git a/redis/connection.py b/redis/connection.py
--- a/redis/connection.py
+++ b/redis/connection.py
@@ -879,6 +879,11 @@ def pack_commands(self, commands):
 
 
 class SSLConnection(Connection):
+    """Manages SSL connections to and from the Redis server(s).
+    This class extends the Connection class, adding SSL functionality, and making
+    use of ssl.SSLContext (https://docs.python.org/3/library/ssl.html#ssl.SSLContext)
+    """  # noqa
+
     def __init__(
         self,
         ssl_keyfile=None,
@@ -886,8 +891,24 @@ def __init__(
         ssl_cert_reqs="required",
         ssl_ca_certs=None,
         ssl_check_hostname=False,
+        ssl_ca_path=None,
+        ssl_password=None,
         **kwargs,
     ):
+        """Constructor
+
+        Args:
+            ssl_keyfile: Path to an ssl private key. Defaults to None.
+            ssl_certfile: Path to an ssl certificate. Defaults to None.
+            ssl_cert_reqs: The string value for the SSLContext.verify_mode (none, optional, required). Defaults to "required".
+            ssl_ca_certs: The path to a file of concatenated CA certificates in PEM format. Defaults to None.
+            ssl_check_hostname: If set, match the hostname during the SSL handshake. Defaults to False.
+            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.
+
+        Raises:
+            RedisError
+        """  # noqa
         if not ssl_available:
             raise RedisError("Python wasn't built with SSL support")
 
@@ -910,7 +931,9 @@ def __init__(
             ssl_cert_reqs = CERT_REQS[ssl_cert_reqs]
         self.cert_reqs = ssl_cert_reqs
         self.ca_certs = ssl_ca_certs
+        self.ca_path = ssl_ca_path
         self.check_hostname = ssl_check_hostname
+        self.certificate_password = ssl_password
 
     def _connect(self):
         "Wrap the socket with SSL support"
@@ -918,10 +941,14 @@ def _connect(self):
         context = ssl.create_default_context()
         context.check_hostname = self.check_hostname
         context.verify_mode = self.cert_reqs
-        if self.certfile and self.keyfile:
-            context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
-        if self.ca_certs:
-            context.load_verify_locations(self.ca_certs)
+        if self.certfile or self.keyfile:
+            context.load_cert_chain(
+                certfile=self.certfile,
+                keyfile=self.keyfile,
+                password=self.certificate_password,
+            )
+        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)
 
 
diff --git a/tasks.py b/tasks.py
--- a/tasks.py
+++ b/tasks.py
@@ -3,6 +3,11 @@
 
 from invoke import run, task
 
+
+def _generate_keys():
+    run("bash docker/stunnel/create_certs.sh")
+
+
 with open("tox.ini") as fp:
     lines = fp.read().split("\n")
     dockers = [line.split("=")[1].strip() for line in lines if line.find("name") != -1]
@@ -14,6 +19,7 @@ def devenv(c):
     specified in the tox.ini file.
     """
     clean(c)
+    _generate_keys()
     cmd = "tox -e devenv"
     for d in dockers:
         cmd += f" --docker-dont-stop={d}"
@@ -29,6 +35,7 @@ def build_docs(c):
 @task
 def linters(c):
     """Run code linters"""
+    _generate_keys()
     run("tox -e linters")
 
 
@@ -37,6 +44,7 @@ def all_tests(c):
     """Run all linters, and tests in redis-py. This assumes you have all
     the python versions specified in the tox.ini file.
     """
+    _generate_keys()
     linters(c)
     tests(c)
 
@@ -47,6 +55,7 @@ def tests(c):
     with and without hiredis.
     """
     print("Starting Redis tests")
+    _generate_keys()
     run("tox -e '{standalone,cluster}'-'{plain,hiredis}'")
 
 
@@ -55,6 +64,7 @@ def standalone_tests(c):
     """Run all Redis tests against the current python,
     with and without hiredis."""
     print("Starting Redis tests")
+    _generate_keys()
     run("tox -e standalone-'{plain,hiredis}'")
 
 
@@ -63,6 +73,7 @@ def cluster_tests(c):
     """Run all Redis Cluster tests against the current python,
     with and without hiredis."""
     print("Starting RedisCluster tests")
+    _generate_keys()
     run("tox -e cluster-'{plain,hiredis}'")
 
 
@@ -74,6 +85,8 @@ def clean(c):
     if os.path.isdir("dist"):
         shutil.rmtree("dist")
     run(f"docker rm -f {' '.join(dockers)}")
+    if os.path.isdir("docker/stunnel/keys"):
+        shutil.rmtree("docker/stunnel/keys")
 
 
 @task


Test patch:
diff --git a/.github/workflows/install_and_test.sh b/.github/workflows/install_and_test.sh
--- a/.github/workflows/install_and_test.sh
+++ b/.github/workflows/install_and_test.sh
@@ -42,4 +42,4 @@ pip install ${PKG}
 pytest -m 'not onlycluster'
 # RedisCluster tests
 CLUSTER_URL="redis://localhost:16379/0"
-pytest -m 'not onlynoncluster and not redismod' --redis-url=${CLUSTER_URL}
+pytest -m 'not onlynoncluster and not redismod and not ssl' --redis-url=${CLUSTER_URL}
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,8 +14,10 @@
 
 REDIS_INFO = {}
 default_redis_url = "redis://localhost:6379/9"
-
 default_redismod_url = "redis://localhost:36379"
+
+# default ssl client ignores verification for the purpose of testing
+default_redis_ssl_url = "rediss://localhost:6666"
 default_cluster_nodes = 6
 
 
@@ -36,6 +38,13 @@ def pytest_addoption(parser):
         " defaults to `%(default)s`",
     )
 
+    parser.addoption(
+        "--redis-ssl-url",
+        default=default_redis_ssl_url,
+        action="store",
+        help="Redis SSL connection string," " defaults to `%(default)s`",
+    )
+
     parser.addoption(
         "--redis-cluster-nodes",
         default=default_cluster_nodes,
@@ -248,6 +257,12 @@ def r2(request):
         yield client
 
 
+@pytest.fixture()
+def sslclient(request):
+    with _get_client(redis.Redis, request, ssl=True) as client:
+        yield client
+
+
 def _gen_cluster_mock_resp(r, response):
     connection = Mock()
     connection.retry = Retry(NoBackoff(), 0)
diff --git a/tests/test_ssl.py b/tests/test_ssl.py
new file mode 100644
--- /dev/null
+++ b/tests/test_ssl.py
@@ -0,0 +1,61 @@
+import os
+from urllib.parse import urlparse
+
+import pytest
+
+import redis
+from redis.exceptions import ConnectionError
+
+
+@pytest.mark.ssl
+class TestSSL:
+    """Tests for SSL connections
+
+    This relies on the --redis-ssl-url purely for rebuilding the client
+    and connecting to the appropriate port.
+    """
+
+    ROOT = os.path.join(os.path.dirname(__file__), "..")
+    CERT_DIR = os.path.abspath(os.path.join(ROOT, "docker", "stunnel", "keys"))
+    if not os.path.isdir(CERT_DIR):  # github actions package validation case
+        CERT_DIR = os.path.abspath(
+            os.path.join(ROOT, "..", "docker", "stunnel", "keys")
+        )
+        if not os.path.isdir(CERT_DIR):
+            raise IOError(f"No SSL certificates found. They should be in {CERT_DIR}")
+
+    def test_ssl_with_invalid_cert(self, request):
+        ssl_url = request.config.option.redis_ssl_url
+        sslclient = redis.from_url(ssl_url)
+        with pytest.raises(ConnectionError) as e:
+            sslclient.ping()
+            assert "SSL: CERTIFICATE_VERIFY_FAILED" in str(e)
+
+    def test_ssl_connection(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_cert_reqs="none")
+        assert r.ping()
+
+    def test_ssl_connection_without_ssl(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=False)
+
+        with pytest.raises(ConnectionError) as e:
+            r.ping()
+            assert "Connection closed by server" in str(e)
+
+    def test_validating_self_signed_certificate(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"),
+        )
+        assert r.ping()