Skip to content

Commit adfabbd

Browse files
committed
Make it possible to customize SSL ciphers
Given that Python 3.10 changed the default list of SSL ciphers, it is a good idea in general to allow customization of the list of cyphers when using Redis with TLS. It seems that this works only with TLS 1.2, and with TLS 1.3 it's intentionally not possible to change the ciphers: https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers
1 parent 1784b37 commit adfabbd

File tree

9 files changed

+245
-0
lines changed

9 files changed

+245
-0
lines changed

redis/asyncio/client.py

+2
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ def __init__(
221221
ssl_ca_data: Optional[str] = None,
222222
ssl_check_hostname: bool = False,
223223
ssl_min_version: Optional[ssl.TLSVersion] = None,
224+
ssl_ciphers: Optional[str] = None,
224225
max_connections: Optional[int] = None,
225226
single_connection_client: bool = False,
226227
health_check_interval: int = 0,
@@ -314,6 +315,7 @@ def __init__(
314315
"ssl_ca_data": ssl_ca_data,
315316
"ssl_check_hostname": ssl_check_hostname,
316317
"ssl_min_version": ssl_min_version,
318+
"ssl_ciphers": ssl_ciphers,
317319
}
318320
)
319321
# This arg only used if no pool is passed in

redis/asyncio/cluster.py

+2
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def __init__(
267267
ssl_check_hostname: bool = False,
268268
ssl_keyfile: Optional[str] = None,
269269
ssl_min_version: Optional[ssl.TLSVersion] = None,
270+
ssl_ciphers: Optional[str] = None,
270271
protocol: Optional[int] = 2,
271272
address_remap: Optional[Callable[[str, int], Tuple[str, int]]] = None,
272273
) -> None:
@@ -326,6 +327,7 @@ def __init__(
326327
"ssl_check_hostname": ssl_check_hostname,
327328
"ssl_keyfile": ssl_keyfile,
328329
"ssl_min_version": ssl_min_version,
330+
"ssl_ciphers": ssl_ciphers,
329331
}
330332
)
331333

redis/asyncio/connection.py

+7
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@ def __init__(
739739
ssl_ca_data: Optional[str] = None,
740740
ssl_check_hostname: bool = False,
741741
ssl_min_version: Optional[ssl.TLSVersion] = None,
742+
ssl_ciphers: Optional[str] = None,
742743
**kwargs,
743744
):
744745
self.ssl_context: RedisSSLContext = RedisSSLContext(
@@ -749,6 +750,7 @@ def __init__(
749750
ca_data=ssl_ca_data,
750751
check_hostname=ssl_check_hostname,
751752
min_version=ssl_min_version,
753+
ciphers=ssl_ciphers,
752754
)
753755
super().__init__(**kwargs)
754756

@@ -796,6 +798,7 @@ class RedisSSLContext:
796798
"context",
797799
"check_hostname",
798800
"min_version",
801+
"ciphers",
799802
)
800803

801804
def __init__(
@@ -807,6 +810,7 @@ def __init__(
807810
ca_data: Optional[str] = None,
808811
check_hostname: bool = False,
809812
min_version: Optional[ssl.TLSVersion] = None,
813+
ciphers: Optional[str] = None,
810814
):
811815
self.keyfile = keyfile
812816
self.certfile = certfile
@@ -827,6 +831,7 @@ def __init__(
827831
self.ca_data = ca_data
828832
self.check_hostname = check_hostname
829833
self.min_version = min_version
834+
self.ciphers = ciphers
830835
self.context: Optional[ssl.SSLContext] = None
831836

832837
def get(self) -> ssl.SSLContext:
@@ -840,6 +845,8 @@ def get(self) -> ssl.SSLContext:
840845
context.load_verify_locations(cafile=self.ca_certs, cadata=self.ca_data)
841846
if self.min_version is not None:
842847
context.minimum_version = self.min_version
848+
if self.ciphers is not None:
849+
context.set_ciphers(self.ciphers)
843850
self.context = context
844851
return self.context
845852

redis/client.py

+2
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def __init__(
198198
ssl_ocsp_context=None,
199199
ssl_ocsp_expected_cert=None,
200200
ssl_min_version=None,
201+
ssl_ciphers=None,
201202
max_connections=None,
202203
single_connection_client=False,
203204
health_check_interval=0,
@@ -298,6 +299,7 @@ def __init__(
298299
"ssl_ocsp_context": ssl_ocsp_context,
299300
"ssl_ocsp_expected_cert": ssl_ocsp_expected_cert,
300301
"ssl_min_version": ssl_min_version,
302+
"ssl_ciphers": ssl_ciphers,
301303
}
302304
)
303305
connection_pool = ConnectionPool(**kwargs)

redis/connection.py

+5
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ def __init__(
685685
ssl_ocsp_context=None,
686686
ssl_ocsp_expected_cert=None,
687687
ssl_min_version=None,
688+
ssl_ciphers=None,
688689
**kwargs,
689690
):
690691
"""Constructor
@@ -704,6 +705,7 @@ def __init__(
704705
ssl_ocsp_context: A fully initialized OpenSSL.SSL.Context object to be used in verifying the ssl_ocsp_expected_cert
705706
ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service.
706707
ssl_min_version: The lowest supported SSL version. It affects the supported SSL versions of the SSLContext. None leaves the default provided by ssl module.
708+
ssl_ciphers: A string listing the ciphers that are allowed to be used. Defaults to None, which means that the default ciphers are used. See https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers for more information.
707709
708710
Raises:
709711
RedisError
@@ -737,6 +739,7 @@ def __init__(
737739
self.ssl_ocsp_context = ssl_ocsp_context
738740
self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert
739741
self.ssl_min_version = ssl_min_version
742+
self.ssl_ciphers = ssl_ciphers
740743
super().__init__(**kwargs)
741744

742745
def _connect(self):
@@ -761,6 +764,8 @@ def _connect(self):
761764
)
762765
if self.ssl_min_version is not None:
763766
context.minimum_version = self.ssl_min_version
767+
if self.ssl_ciphers:
768+
context.set_ciphers(self.ssl_ciphers)
764769
sslsock = context.wrap_socket(sock, server_hostname=self.host)
765770
if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False:
766771
raise RedisError("cryptography is not installed.")

tests/test_asyncio/test_cluster.py

+42
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import binascii
33
import datetime
4+
import ssl
45
import warnings
56
from typing import Any, Awaitable, Callable, Dict, List, Optional, Type, Union
67
from urllib.parse import urlparse
@@ -2951,6 +2952,47 @@ async def test_ssl_connection(
29512952
async with await create_client(ssl=True, ssl_cert_reqs="none") as rc:
29522953
assert await rc.ping()
29532954

2955+
@pytest.mark.parametrize(
2956+
"ssl_ciphers",
2957+
[
2958+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
2959+
"ECDHE-ECDSA-AES256-GCM-SHA384",
2960+
"ECDHE-RSA-AES128-GCM-SHA256",
2961+
],
2962+
)
2963+
async def test_ssl_connection_tls12_custom_ciphers(
2964+
self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]
2965+
) -> None:
2966+
async with await create_client(ssl=True, ssl_cert_reqs="none",
2967+
ssl_min_version=ssl.TLSVersion.TLSv1_2, ssl_ciphers=ssl_ciphers) as rc:
2968+
assert await rc.ping()
2969+
2970+
async def test_ssl_connection_tls12_custom_ciphers_invalid(
2971+
self, create_client: Callable[..., Awaitable[RedisCluster]]
2972+
) -> None:
2973+
async with await create_client(ssl=True, ssl_cert_reqs="none",
2974+
ssl_min_version=ssl.TLSVersion.TLSv1_2, ssl_ciphers="foo:bar") as rc:
2975+
with pytest.raises(RedisClusterException) as e:
2976+
assert await rc.ping()
2977+
assert "Redis Cluster cannot be connected" in str(e.value)
2978+
2979+
@pytest.mark.parametrize(
2980+
"ssl_ciphers",
2981+
[
2982+
"TLS_CHACHA20_POLY1305_SHA256",
2983+
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"
2984+
],
2985+
)
2986+
async def test_ssl_connection_tls13_custom_ciphers(
2987+
self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]
2988+
) -> None:
2989+
# TLSv1.3 does not support changing the ciphers
2990+
async with await create_client(ssl=True, ssl_cert_reqs="none",
2991+
ssl_min_version=ssl.TLSVersion.TLSv1_2, ssl_ciphers=ssl_ciphers) as rc:
2992+
with pytest.raises(RedisClusterException) as e:
2993+
assert await rc.ping()
2994+
assert "Redis Cluster cannot be connected" in str(e.value)
2995+
29542996
async def test_validating_self_signed_certificate(
29552997
self, create_client: Callable[..., Awaitable[RedisCluster]]
29562998
) -> None:

tests/test_asyncio/test_connect.py

+73
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,79 @@ async def test_uds_connect(uds_address):
5050
await _assert_connect(conn, path)
5151

5252

53+
@pytest.mark.ssl
54+
@pytest.mark.parametrize(
55+
"ssl_ciphers",
56+
[
57+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
58+
"ECDHE-ECDSA-AES256-GCM-SHA384",
59+
"ECDHE-RSA-AES128-GCM-SHA256",
60+
],
61+
)
62+
async def test_tcp_ssl_tls12_custom_ciphers(tcp_address, ssl_ciphers):
63+
host, port = tcp_address
64+
certfile = get_ssl_filename("server-cert.pem")
65+
keyfile = get_ssl_filename("server-key.pem")
66+
conn = SSLConnection(
67+
host=host,
68+
port=port,
69+
client_name=_CLIENT_NAME,
70+
ssl_ca_certs=certfile,
71+
socket_timeout=10,
72+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
73+
ssl_ciphers=ssl_ciphers,
74+
)
75+
await _assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
76+
await conn.disconnect()
77+
78+
79+
async def test_tcp_ssl_tls12_custom_ciphers_invalid(tcp_address):
80+
host, port = tcp_address
81+
certfile = get_ssl_filename("server-cert.pem")
82+
keyfile = get_ssl_filename("server-key.pem")
83+
conn = SSLConnection(
84+
host=host,
85+
port=port,
86+
client_name=_CLIENT_NAME,
87+
ssl_ca_certs=certfile,
88+
socket_timeout=10,
89+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
90+
ssl_ciphers="foo:bar",
91+
)
92+
with pytest.raises(ConnectionError) as e:
93+
await _assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
94+
assert "No cipher can be selected" in str(e.value)
95+
await conn.disconnect()
96+
97+
98+
@pytest.mark.ssl
99+
@pytest.mark.parametrize(
100+
"ssl_ciphers",
101+
[
102+
"TLS_AES_256_GCM_SHA384",
103+
"TLS_CHACHA20_POLY1305_SHA256",
104+
],
105+
)
106+
async def test_tcp_ssl_tls13_custom_ciphers(tcp_address, ssl_ciphers):
107+
# TLSv1.3 does not support changing the ciphers
108+
host, port = tcp_address
109+
certfile = get_ssl_filename("server-cert.pem")
110+
keyfile = get_ssl_filename("server-key.pem")
111+
conn = SSLConnection(
112+
host=host,
113+
port=port,
114+
client_name=_CLIENT_NAME,
115+
ssl_ca_certs=certfile,
116+
socket_timeout=10,
117+
ssl_min_version=ssl.TLSVersion.TLSv1_3,
118+
ssl_ciphers=ssl_ciphers,
119+
)
120+
with pytest.raises(ConnectionError) as e:
121+
await _assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
122+
assert "No cipher can be selected" in str(e.value)
123+
await conn.disconnect()
124+
125+
53126
@pytest.mark.ssl
54127
@pytest.mark.parametrize(
55128
"ssl_min_version",

tests/test_connect.py

+66
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,72 @@ def test_tcp_ssl_connect(tcp_address, ssl_min_version):
7171
_assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
7272

7373

74+
@pytest.mark.ssl
75+
@pytest.mark.parametrize(
76+
"ssl_ciphers",
77+
[
78+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
79+
"ECDHE-ECDSA-AES256-GCM-SHA384",
80+
"ECDHE-RSA-AES128-GCM-SHA256",
81+
],
82+
)
83+
def test_tcp_ssl_tls12_custom_ciphers(tcp_address, ssl_ciphers):
84+
host, port = tcp_address
85+
certfile = get_ssl_filename("server-cert.pem")
86+
keyfile = get_ssl_filename("server-key.pem")
87+
conn = SSLConnection(
88+
host=host,
89+
port=port,
90+
client_name=_CLIENT_NAME,
91+
ssl_ca_certs=certfile,
92+
socket_timeout=10,
93+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
94+
ssl_ciphers=ssl_ciphers,
95+
)
96+
_assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
97+
98+
99+
def test_tcp_ssl_tls12_custom_ciphers_invalid(tcp_address):
100+
host, port = tcp_address
101+
certfile = get_ssl_filename("server-cert.pem")
102+
keyfile = get_ssl_filename("server-key.pem")
103+
conn = SSLConnection(
104+
host=host,
105+
port=port,
106+
client_name=_CLIENT_NAME,
107+
ssl_ca_certs=certfile,
108+
socket_timeout=10,
109+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
110+
ssl_ciphers="foo:bar",
111+
)
112+
_assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
113+
114+
115+
@pytest.mark.ssl
116+
@pytest.mark.parametrize(
117+
"ssl_ciphers",
118+
[
119+
"TLS_AES_256_GCM_SHA384",
120+
"TLS_CHACHA20_POLY1305_SHA256",
121+
],
122+
)
123+
def test_tcp_ssl_tls13_custom_ciphers(tcp_address, ssl_ciphers):
124+
# TLSv1.3 does not support changing the ciphers
125+
host, port = tcp_address
126+
certfile = get_ssl_filename("server-cert.pem")
127+
keyfile = get_ssl_filename("server-key.pem")
128+
conn = SSLConnection(
129+
host=host,
130+
port=port,
131+
client_name=_CLIENT_NAME,
132+
ssl_ca_certs=certfile,
133+
socket_timeout=10,
134+
ssl_min_version=ssl.TLSVersion.TLSv1_3,
135+
ssl_ciphers=ssl_ciphers,
136+
)
137+
_assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
138+
139+
74140
@pytest.mark.ssl
75141
@pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason="requires TLSv1.3")
76142
def test_tcp_ssl_version_mismatch(tcp_address):

tests/test_ssl.py

+46
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,52 @@ def test_validating_self_signed_string_certificate(self, request):
7373
)
7474
assert r.ping()
7575

76+
@pytest.mark.ssl
77+
@pytest.mark.parametrize(
78+
"ssl_ciphers",
79+
[
80+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
81+
"DHE-RSA-AES256-GCM-SHA384",
82+
"ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305",
83+
],
84+
)
85+
def test_ssl_connection_tls12_custom_ciphers(self, request, ssl_ciphers):
86+
ssl_url = request.config.option.redis_ssl_url
87+
p = urlparse(ssl_url)[1].split(":")
88+
r = redis.Redis(host=p[0], port=p[1], ssl=True, ssl_cert_reqs="none",
89+
ssl_min_version=ssl.TLSVersion.TLSv1_3, ssl_ciphers=ssl_ciphers)
90+
assert r.ping()
91+
r.close()
92+
93+
def test_ssl_connection_tls12_custom_ciphers_invalid(self, request):
94+
ssl_url = request.config.option.redis_ssl_url
95+
p = urlparse(ssl_url)[1].split(":")
96+
r = redis.Redis(host=p[0], port=p[1], ssl=True, ssl_cert_reqs="none",
97+
ssl_min_version=ssl.TLSVersion.TLSv1_2, ssl_ciphers="foo:bar")
98+
with pytest.raises(RedisError) as e:
99+
r.ping()
100+
assert "No cipher can be selected" in str(e)
101+
r.close()
102+
103+
@pytest.mark.ssl
104+
@pytest.mark.parametrize(
105+
"ssl_ciphers",
106+
[
107+
"TLS_CHACHA20_POLY1305_SHA256",
108+
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"
109+
],
110+
)
111+
def test_ssl_connection_tls13_custom_ciphers(self, request, ssl_ciphers):
112+
# TLSv1.3 does not support changing the ciphers
113+
ssl_url = request.config.option.redis_ssl_url
114+
p = urlparse(ssl_url)[1].split(":")
115+
r = redis.Redis(host=p[0], port=p[1], ssl=True, ssl_cert_reqs="none",
116+
ssl_min_version=ssl.TLSVersion.TLSv1_2, ssl_ciphers=ssl_ciphers)
117+
with pytest.raises(RedisError) as e:
118+
r.ping()
119+
assert "No cipher can be selected" in str(e)
120+
r.close()
121+
76122
def _create_oscp_conn(self, request):
77123
ssl_url = request.config.option.redis_ssl_url
78124
p = urlparse(ssl_url)[1].split(":")

0 commit comments

Comments
 (0)