Skip to content

Commit 1e2998a

Browse files
committed
Make it possible to customize SSL ciphers (redis#3212)
Given that Python 3.10 changed the default list of TLS ciphers, it is a good idea to allow customization of the list of ciphers when using Redis with TLS. In some situations the client is unusable right now with older servers and Python >= 3.10.
1 parent 07fc339 commit 1e2998a

File tree

9 files changed

+185
-0
lines changed

9 files changed

+185
-0
lines changed

redis/asyncio/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ def __init__(
226226
ssl_ca_data: Optional[str] = None,
227227
ssl_check_hostname: bool = False,
228228
ssl_min_version: Optional[ssl.TLSVersion] = None,
229+
ssl_ciphers: Optional[str] = None,
229230
max_connections: Optional[int] = None,
230231
single_connection_client: bool = False,
231232
health_check_interval: int = 0,
@@ -333,6 +334,7 @@ def __init__(
333334
"ssl_ca_data": ssl_ca_data,
334335
"ssl_check_hostname": ssl_check_hostname,
335336
"ssl_min_version": ssl_min_version,
337+
"ssl_ciphers": ssl_ciphers,
336338
}
337339
)
338340
# This arg only used if no pool is passed in

redis/asyncio/cluster.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ def __init__(
273273
ssl_check_hostname: bool = False,
274274
ssl_keyfile: Optional[str] = None,
275275
ssl_min_version: Optional[ssl.TLSVersion] = None,
276+
ssl_ciphers: Optional[str] = None,
276277
protocol: Optional[int] = 2,
277278
address_remap: Optional[Callable[[str, int], Tuple[str, int]]] = None,
278279
cache_enabled: bool = False,
@@ -347,6 +348,7 @@ def __init__(
347348
"ssl_check_hostname": ssl_check_hostname,
348349
"ssl_keyfile": ssl_keyfile,
349350
"ssl_min_version": ssl_min_version,
351+
"ssl_ciphers": ssl_ciphers,
350352
}
351353
)
352354

redis/asyncio/connection.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,7 @@ def __init__(
824824
ssl_ca_data: Optional[str] = None,
825825
ssl_check_hostname: bool = False,
826826
ssl_min_version: Optional[ssl.TLSVersion] = None,
827+
ssl_ciphers: Optional[str] = None,
827828
**kwargs,
828829
):
829830
self.ssl_context: RedisSSLContext = RedisSSLContext(
@@ -834,6 +835,7 @@ def __init__(
834835
ca_data=ssl_ca_data,
835836
check_hostname=ssl_check_hostname,
836837
min_version=ssl_min_version,
838+
ciphers=ssl_ciphers,
837839
)
838840
super().__init__(**kwargs)
839841

@@ -881,6 +883,7 @@ class RedisSSLContext:
881883
"context",
882884
"check_hostname",
883885
"min_version",
886+
"ciphers",
884887
)
885888

886889
def __init__(
@@ -892,6 +895,7 @@ def __init__(
892895
ca_data: Optional[str] = None,
893896
check_hostname: bool = False,
894897
min_version: Optional[ssl.TLSVersion] = None,
898+
ciphers: Optional[str] = None,
895899
):
896900
self.keyfile = keyfile
897901
self.certfile = certfile
@@ -912,6 +916,7 @@ def __init__(
912916
self.ca_data = ca_data
913917
self.check_hostname = check_hostname
914918
self.min_version = min_version
919+
self.ciphers = ciphers
915920
self.context: Optional[ssl.SSLContext] = None
916921

917922
def get(self) -> ssl.SSLContext:
@@ -925,6 +930,8 @@ def get(self) -> ssl.SSLContext:
925930
context.load_verify_locations(cafile=self.ca_certs, cadata=self.ca_data)
926931
if self.min_version is not None:
927932
context.minimum_version = self.min_version
933+
if self.ciphers is not None:
934+
context.set_ciphers(self.ciphers)
928935
self.context = context
929936
return self.context
930937

redis/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def __init__(
204204
ssl_ocsp_context=None,
205205
ssl_ocsp_expected_cert=None,
206206
ssl_min_version=None,
207+
ssl_ciphers=None,
207208
max_connections=None,
208209
single_connection_client=False,
209210
health_check_interval=0,
@@ -318,6 +319,7 @@ def __init__(
318319
"ssl_ocsp_context": ssl_ocsp_context,
319320
"ssl_ocsp_expected_cert": ssl_ocsp_expected_cert,
320321
"ssl_min_version": ssl_min_version,
322+
"ssl_ciphers": ssl_ciphers,
321323
}
322324
)
323325
connection_pool = ConnectionPool(**kwargs)

redis/connection.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,7 @@ def __init__(
764764
ssl_ocsp_context=None,
765765
ssl_ocsp_expected_cert=None,
766766
ssl_min_version=None,
767+
ssl_ciphers=None,
767768
**kwargs,
768769
):
769770
"""Constructor
@@ -783,6 +784,7 @@ def __init__(
783784
ssl_ocsp_context: A fully initialized OpenSSL.SSL.Context object to be used in verifying the ssl_ocsp_expected_cert
784785
ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service.
785786
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.
787+
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.
786788
787789
Raises:
788790
RedisError
@@ -816,6 +818,7 @@ def __init__(
816818
self.ssl_ocsp_context = ssl_ocsp_context
817819
self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert
818820
self.ssl_min_version = ssl_min_version
821+
self.ssl_ciphers = ssl_ciphers
819822
super().__init__(**kwargs)
820823

821824
def _connect(self):
@@ -840,6 +843,8 @@ def _connect(self):
840843
)
841844
if self.ssl_min_version is not None:
842845
context.minimum_version = self.ssl_min_version
846+
if self.ssl_ciphers:
847+
context.set_ciphers(self.ssl_ciphers)
843848
sslsock = context.wrap_socket(sock, server_hostname=self.host)
844849
if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False:
845850
raise RedisError("cryptography is not installed.")

tests/test_asyncio/test_cluster.py

Lines changed: 54 additions & 0 deletions
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
@@ -2961,6 +2962,59 @@ async def test_ssl_connection(
29612962
async with await create_client(ssl=True, ssl_cert_reqs="none") as rc:
29622963
assert await rc.ping()
29632964

2965+
@pytest.mark.parametrize(
2966+
"ssl_ciphers",
2967+
[
2968+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
2969+
"ECDHE-ECDSA-AES256-GCM-SHA384",
2970+
"ECDHE-RSA-AES128-GCM-SHA256",
2971+
],
2972+
)
2973+
async def test_ssl_connection_tls12_custom_ciphers(
2974+
self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]
2975+
) -> None:
2976+
async with await create_client(
2977+
ssl=True,
2978+
ssl_cert_reqs="none",
2979+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
2980+
ssl_ciphers=ssl_ciphers,
2981+
) as rc:
2982+
assert await rc.ping()
2983+
2984+
async def test_ssl_connection_tls12_custom_ciphers_invalid(
2985+
self, create_client: Callable[..., Awaitable[RedisCluster]]
2986+
) -> None:
2987+
async with await create_client(
2988+
ssl=True,
2989+
ssl_cert_reqs="none",
2990+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
2991+
ssl_ciphers="foo:bar",
2992+
) as rc:
2993+
with pytest.raises(RedisClusterException) as e:
2994+
assert await rc.ping()
2995+
assert "Redis Cluster cannot be connected" in str(e.value)
2996+
2997+
@pytest.mark.parametrize(
2998+
"ssl_ciphers",
2999+
[
3000+
"TLS_CHACHA20_POLY1305_SHA256",
3001+
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
3002+
],
3003+
)
3004+
async def test_ssl_connection_tls13_custom_ciphers(
3005+
self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]
3006+
) -> None:
3007+
# TLSv1.3 does not support changing the ciphers
3008+
async with await create_client(
3009+
ssl=True,
3010+
ssl_cert_reqs="none",
3011+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
3012+
ssl_ciphers=ssl_ciphers,
3013+
) as rc:
3014+
with pytest.raises(RedisClusterException) as e:
3015+
assert await rc.ping()
3016+
assert "Redis Cluster cannot be connected" in str(e.value)
3017+
29643018
async def test_validating_self_signed_certificate(
29653019
self, create_client: Callable[..., Awaitable[RedisCluster]]
29663020
) -> None:

tests/test_asyncio/test_connect.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,32 @@ 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+
5379
@pytest.mark.ssl
5480
@pytest.mark.parametrize(
5581
"ssl_min_version",

tests/test_connect.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,31 @@ 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+
7499
@pytest.mark.ssl
75100
@pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason="requires TLSv1.3")
76101
def test_tcp_ssl_version_mismatch(tcp_address):

tests/test_ssl.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,68 @@ def test_validating_self_signed_string_certificate(self, request):
7878
assert r.ping()
7979
r.close()
8080

81+
@pytest.mark.parametrize(
82+
"ssl_ciphers",
83+
[
84+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
85+
"DHE-RSA-AES256-GCM-SHA384",
86+
"ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305",
87+
],
88+
)
89+
def test_ssl_connection_tls12_custom_ciphers(self, request, ssl_ciphers):
90+
ssl_url = request.config.option.redis_ssl_url
91+
p = urlparse(ssl_url)[1].split(":")
92+
r = redis.Redis(
93+
host=p[0],
94+
port=p[1],
95+
ssl=True,
96+
ssl_cert_reqs="none",
97+
ssl_min_version=ssl.TLSVersion.TLSv1_3,
98+
ssl_ciphers=ssl_ciphers,
99+
)
100+
assert r.ping()
101+
r.close()
102+
103+
def test_ssl_connection_tls12_custom_ciphers_invalid(self, request):
104+
ssl_url = request.config.option.redis_ssl_url
105+
p = urlparse(ssl_url)[1].split(":")
106+
r = redis.Redis(
107+
host=p[0],
108+
port=p[1],
109+
ssl=True,
110+
ssl_cert_reqs="none",
111+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
112+
ssl_ciphers="foo:bar",
113+
)
114+
with pytest.raises(RedisError) as e:
115+
r.ping()
116+
assert "No cipher can be selected" in str(e)
117+
r.close()
118+
119+
@pytest.mark.parametrize(
120+
"ssl_ciphers",
121+
[
122+
"TLS_CHACHA20_POLY1305_SHA256",
123+
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
124+
],
125+
)
126+
def test_ssl_connection_tls13_custom_ciphers(self, request, ssl_ciphers):
127+
# TLSv1.3 does not support changing the ciphers
128+
ssl_url = request.config.option.redis_ssl_url
129+
p = urlparse(ssl_url)[1].split(":")
130+
r = redis.Redis(
131+
host=p[0],
132+
port=p[1],
133+
ssl=True,
134+
ssl_cert_reqs="none",
135+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
136+
ssl_ciphers=ssl_ciphers,
137+
)
138+
with pytest.raises(RedisError) as e:
139+
r.ping()
140+
assert "No cipher can be selected" in str(e)
141+
r.close()
142+
81143
def _create_oscp_conn(self, request):
82144
ssl_url = request.config.option.redis_ssl_url
83145
p = urlparse(ssl_url)[1].split(":")

0 commit comments

Comments
 (0)