Skip to content

Commit 18c6809

Browse files
authored
Support for password-encrypted SSL private keys (#1782)
Adding support for SSL private keys with a password. This PR also adds support for future SSL tests.
1 parent a8b8f14 commit 18c6809

17 files changed

+215
-17
lines changed

.github/workflows/install_and_test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ pip install ${PKG}
4242
pytest -m 'not onlycluster'
4343
# RedisCluster tests
4444
CLUSTER_URL="redis://localhost:16379/0"
45-
pytest -m 'not onlynoncluster and not redismod' --redis-url=${CLUSTER_URL}
45+
pytest -m 'not onlynoncluster and not redismod and not ssl' --redis-url=${CLUSTER_URL}

.github/workflows/integration.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ jobs:
4848
- name: run tests
4949
run: |
5050
pip install -r dev_requirements.txt
51+
bash docker/stunnel/create_certs.sh
5152
tox -e ${{matrix.test-type}}-${{matrix.connection-type}}
5253
- name: Upload codecov coverage
5354
uses: codecov/codecov-action@v2

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ coverage.xml
1717
.venv
1818
*.xml
1919
.coverage*
20+
docker/stunnel/keys

CONTRIBUTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ can execute docker and its various commands.
5858
- A Redis replica node
5959
- Three sentinel Redis nodes
6060
- A multi-python docker, with your source code mounted in /data
61+
- An stunnel docker, fronting the master Redis node
6162

6263
The replica node, is a replica of the master node, using the
6364
[leader-follower replication](https://redis.io/topics/replication)
@@ -73,7 +74,7 @@ tests as well. With the 'tests' and 'all-tests' targets, all Redis and
7374
RedisCluster tests will be run.
7475

7576
It is possible to run only Redis client tests (with cluster mode disabled) by
76-
using `invoke redis-tests`; similarly, RedisCluster tests can be run by using
77+
using `invoke standalone-tests`; similarly, RedisCluster tests can be run by using
7778
`invoke cluster-tests`.
7879

7980
Each run of tox starts and stops the various dockers required. Sometimes

dev_requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pytest==6.2.5
66
pytest-timeout==2.0.1
77
tox==3.24.4
88
tox-docker==3.1.0
9+
tox-run-before==0.1
910
invoke==1.6.0
1011
pytest-cov>=3.0.0
1112
vulture>=2.3.0

docker/base/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# produces redisfab/redis-py:6.2.6
12
FROM redis:6.2.6-buster
23

34
CMD ["redis-server", "/redis.conf"]

docker/base/Dockerfile.cluster

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
# produces redisfab/redis-py-cluster:6.2.6
12
FROM redis:6.2.6-buster
23

34
COPY create_cluster.sh /create_cluster.sh
45
RUN chmod +x /create_cluster.sh
56

67
EXPOSE 16379 16380 16381 16382 16383 16384
78

8-
CMD [ "/create_cluster.sh"]
9+
CMD [ "/create_cluster.sh"]

docker/base/Dockerfile.sentinel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# produces redisfab/redis-py-sentinel:6.2.6
12
FROM redis:6.2.6-buster
23

34
CMD ["redis-sentinel", "/sentinel.conf"]

docker/base/Dockerfile.stunnel

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# produces redisfab/stunnel:latest
2+
FROM ubuntu:18.04
3+
4+
RUN apt-get update -qq --fix-missing
5+
RUN apt-get upgrade -qqy
6+
RUN apt install -qqy stunnel
7+
RUN mkdir -p /etc/stunnel/conf.d
8+
RUN echo "foreground = yes\ninclude = /etc/stunnel/conf.d" > /etc/stunnel/stunnel.conf
9+
RUN chown -R root:root /etc/stunnel/
10+
11+
CMD ["/usr/bin/stunnel"]

docker/stunnel/conf/redis.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[redis]
2+
accept = 6666
3+
connect = master:6379
4+
cert = /etc/stunnel/keys/server-cert.pem
5+
key = /etc/stunnel/keys/server-key.pem
6+
verify = 0

docker/stunnel/create_certs.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/bin/bash
2+
3+
set -e
4+
5+
DESTDIR=`dirname "$0"`/keys
6+
test -d ${DESTDIR} || mkdir ${DESTDIR}
7+
cd ${DESTDIR}
8+
9+
SSL_SUBJECT="/C=CA/ST=Winnipeg/L=Manitoba/O=Some Corp/OU=IT Department/CN=example.com"
10+
which openssl &>/dev/null
11+
if [ $? -ne 0 ]; then
12+
echo "No openssl binary present, exiting."
13+
exit 1
14+
fi
15+
16+
openssl genrsa -out ca-key.pem 2048 &>/dev/null
17+
18+
openssl req -new -x509 -nodes -days 365000 \
19+
-key ca-key.pem \
20+
-out ca-cert.pem \
21+
-subj "${SSL_SUBJECT}" &>/dev/null
22+
23+
openssl req -newkey rsa:2048 -nodes -days 365000 \
24+
-keyout server-key.pem \
25+
-out server-req.pem \
26+
-subj "${SSL_SUBJECT}" &>/dev/null
27+
28+
openssl x509 -req -days 365000 -set_serial 01 \
29+
-in server-req.pem \
30+
-out server-cert.pem \
31+
-CA ca-cert.pem \
32+
-CAkey ca-key.pem &>/dev/null
33+
34+
openssl req -newkey rsa:2048 -nodes -days 365000 \
35+
-keyout client-key.pem \
36+
-out client-req.pem \
37+
-subj "${SSL_SUBJECT}" &>/dev/null
38+
39+
openssl x509 -req -days 365000 -set_serial 01 \
40+
-in client-req.pem \
41+
-out client-cert.pem \
42+
-CA ca-cert.pem \
43+
-CAkey ca-key.pem &>/dev/null
44+
45+
echo "Keys generated in ${DESTDIR}:"
46+
ls

redis/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,9 @@ def __init__(
873873
ssl_certfile=None,
874874
ssl_cert_reqs="required",
875875
ssl_ca_certs=None,
876+
ssl_ca_path=None,
876877
ssl_check_hostname=False,
878+
ssl_password=None,
877879
max_connections=None,
878880
single_connection_client=False,
879881
health_check_interval=0,
@@ -947,6 +949,7 @@ def __init__(
947949
"ssl_cert_reqs": ssl_cert_reqs,
948950
"ssl_ca_certs": ssl_ca_certs,
949951
"ssl_check_hostname": ssl_check_hostname,
952+
"ssl_password": ssl_password,
950953
}
951954
)
952955
connection_pool = ConnectionPool(**kwargs)

redis/connection.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -884,15 +884,36 @@ def pack_commands(self, commands):
884884

885885

886886
class SSLConnection(Connection):
887+
"""Manages SSL connections to and from the Redis server(s).
888+
This class extends the Connection class, adding SSL functionality, and making
889+
use of ssl.SSLContext (https://docs.python.org/3/library/ssl.html#ssl.SSLContext)
890+
""" # noqa
891+
887892
def __init__(
888893
self,
889894
ssl_keyfile=None,
890895
ssl_certfile=None,
891896
ssl_cert_reqs="required",
892897
ssl_ca_certs=None,
893898
ssl_check_hostname=False,
899+
ssl_ca_path=None,
900+
ssl_password=None,
894901
**kwargs,
895902
):
903+
"""Constructor
904+
905+
Args:
906+
ssl_keyfile: Path to an ssl private key. Defaults to None.
907+
ssl_certfile: Path to an ssl certificate. Defaults to None.
908+
ssl_cert_reqs: The string value for the SSLContext.verify_mode (none, optional, required). Defaults to "required".
909+
ssl_ca_certs: The path to a file of concatenated CA certificates in PEM format. Defaults to None.
910+
ssl_check_hostname: If set, match the hostname during the SSL handshake. Defaults to False.
911+
ssl_ca_path: The path to a directory containing several CA certificates in PEM format. Defaults to None.
912+
ssl_password: Password for unlocking an encrypted private key. Defaults to None.
913+
914+
Raises:
915+
RedisError
916+
""" # noqa
896917
if not ssl_available:
897918
raise RedisError("Python wasn't built with SSL support")
898919

@@ -915,18 +936,24 @@ def __init__(
915936
ssl_cert_reqs = CERT_REQS[ssl_cert_reqs]
916937
self.cert_reqs = ssl_cert_reqs
917938
self.ca_certs = ssl_ca_certs
939+
self.ca_path = ssl_ca_path
918940
self.check_hostname = ssl_check_hostname
941+
self.certificate_password = ssl_password
919942

920943
def _connect(self):
921944
"Wrap the socket with SSL support"
922945
sock = super()._connect()
923946
context = ssl.create_default_context()
924947
context.check_hostname = self.check_hostname
925948
context.verify_mode = self.cert_reqs
926-
if self.certfile and self.keyfile:
927-
context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
928-
if self.ca_certs:
929-
context.load_verify_locations(self.ca_certs)
949+
if self.certfile or self.keyfile:
950+
context.load_cert_chain(
951+
certfile=self.certfile,
952+
keyfile=self.keyfile,
953+
password=self.certificate_password,
954+
)
955+
if self.ca_certs is not None or self.ca_path is not None:
956+
context.load_verify_locations(cafile=self.ca_certs, capath=self.ca_path)
930957
return context.wrap_socket(sock, server_hostname=self.host)
931958

932959

tasks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33

44
from invoke import run, task
55

6+
7+
def _generate_keys():
8+
run("bash docker/stunnel/create_certs.sh")
9+
10+
611
with open("tox.ini") as fp:
712
lines = fp.read().split("\n")
813
dockers = [line.split("=")[1].strip() for line in lines if line.find("name") != -1]
@@ -14,6 +19,7 @@ def devenv(c):
1419
specified in the tox.ini file.
1520
"""
1621
clean(c)
22+
_generate_keys()
1723
cmd = "tox -e devenv"
1824
for d in dockers:
1925
cmd += f" --docker-dont-stop={d}"
@@ -29,6 +35,7 @@ def build_docs(c):
2935
@task
3036
def linters(c):
3137
"""Run code linters"""
38+
_generate_keys()
3239
run("tox -e linters")
3340

3441

@@ -37,6 +44,7 @@ def all_tests(c):
3744
"""Run all linters, and tests in redis-py. This assumes you have all
3845
the python versions specified in the tox.ini file.
3946
"""
47+
_generate_keys()
4048
linters(c)
4149
tests(c)
4250

@@ -47,6 +55,7 @@ def tests(c):
4755
with and without hiredis.
4856
"""
4957
print("Starting Redis tests")
58+
_generate_keys()
5059
run("tox -e '{standalone,cluster}'-'{plain,hiredis}'")
5160

5261

@@ -55,6 +64,7 @@ def standalone_tests(c):
5564
"""Run all Redis tests against the current python,
5665
with and without hiredis."""
5766
print("Starting Redis tests")
67+
_generate_keys()
5868
run("tox -e standalone-'{plain,hiredis}'")
5969

6070

@@ -63,6 +73,7 @@ def cluster_tests(c):
6373
"""Run all Redis Cluster tests against the current python,
6474
with and without hiredis."""
6575
print("Starting RedisCluster tests")
76+
_generate_keys()
6677
run("tox -e cluster-'{plain,hiredis}'")
6778

6879

@@ -74,6 +85,8 @@ def clean(c):
7485
if os.path.isdir("dist"):
7586
shutil.rmtree("dist")
7687
run(f"docker rm -f {' '.join(dockers)}")
88+
if os.path.isdir("docker/stunnel/keys"):
89+
shutil.rmtree("docker/stunnel/keys")
7790

7891

7992
@task

tests/conftest.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414

1515
REDIS_INFO = {}
1616
default_redis_url = "redis://localhost:6379/9"
17-
1817
default_redismod_url = "redis://localhost:36379"
18+
19+
# default ssl client ignores verification for the purpose of testing
20+
default_redis_ssl_url = "rediss://localhost:6666"
1921
default_cluster_nodes = 6
2022

2123

@@ -36,6 +38,13 @@ def pytest_addoption(parser):
3638
" defaults to `%(default)s`",
3739
)
3840

41+
parser.addoption(
42+
"--redis-ssl-url",
43+
default=default_redis_ssl_url,
44+
action="store",
45+
help="Redis SSL connection string," " defaults to `%(default)s`",
46+
)
47+
3948
parser.addoption(
4049
"--redis-cluster-nodes",
4150
default=default_cluster_nodes,
@@ -248,6 +257,12 @@ def r2(request):
248257
yield client
249258

250259

260+
@pytest.fixture()
261+
def sslclient(request):
262+
with _get_client(redis.Redis, request, ssl=True) as client:
263+
yield client
264+
265+
251266
def _gen_cluster_mock_resp(r, response):
252267
connection = Mock()
253268
connection.retry = Retry(NoBackoff(), 0)

tests/test_ssl.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import os
2+
from urllib.parse import urlparse
3+
4+
import pytest
5+
6+
import redis
7+
from redis.exceptions import ConnectionError
8+
9+
10+
@pytest.mark.ssl
11+
class TestSSL:
12+
"""Tests for SSL connections
13+
14+
This relies on the --redis-ssl-url purely for rebuilding the client
15+
and connecting to the appropriate port.
16+
"""
17+
18+
ROOT = os.path.join(os.path.dirname(__file__), "..")
19+
CERT_DIR = os.path.abspath(os.path.join(ROOT, "docker", "stunnel", "keys"))
20+
if not os.path.isdir(CERT_DIR): # github actions package validation case
21+
CERT_DIR = os.path.abspath(
22+
os.path.join(ROOT, "..", "docker", "stunnel", "keys")
23+
)
24+
if not os.path.isdir(CERT_DIR):
25+
raise IOError(f"No SSL certificates found. They should be in {CERT_DIR}")
26+
27+
def test_ssl_with_invalid_cert(self, request):
28+
ssl_url = request.config.option.redis_ssl_url
29+
sslclient = redis.from_url(ssl_url)
30+
with pytest.raises(ConnectionError) as e:
31+
sslclient.ping()
32+
assert "SSL: CERTIFICATE_VERIFY_FAILED" in str(e)
33+
34+
def test_ssl_connection(self, request):
35+
ssl_url = request.config.option.redis_ssl_url
36+
p = urlparse(ssl_url)[1].split(":")
37+
r = redis.Redis(host=p[0], port=p[1], ssl=True, ssl_cert_reqs="none")
38+
assert r.ping()
39+
40+
def test_ssl_connection_without_ssl(self, request):
41+
ssl_url = request.config.option.redis_ssl_url
42+
p = urlparse(ssl_url)[1].split(":")
43+
r = redis.Redis(host=p[0], port=p[1], ssl=False)
44+
45+
with pytest.raises(ConnectionError) as e:
46+
r.ping()
47+
assert "Connection closed by server" in str(e)
48+
49+
def test_validating_self_signed_certificate(self, request):
50+
ssl_url = request.config.option.redis_ssl_url
51+
p = urlparse(ssl_url)[1].split(":")
52+
r = redis.Redis(
53+
host=p[0],
54+
port=p[1],
55+
ssl=True,
56+
ssl_certfile=os.path.join(self.CERT_DIR, "server-cert.pem"),
57+
ssl_keyfile=os.path.join(self.CERT_DIR, "server-key.pem"),
58+
ssl_cert_reqs="required",
59+
ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"),
60+
)
61+
assert r.ping()

0 commit comments

Comments
 (0)