Skip to content

Commit 5a713f1

Browse files
Add TLS session resumption integration test
Tests TLS ticket resumption end-to-end using a dynamically generated CA + server certificate pair. The test spins up a single-node CCM cluster configured for TLS, opens multiple connections, and verifies that subsequent connections reuse the TLS session rather than performing a full handshake. Skips automatically when the Scylla CCM node does not support server-side TLS session resumption (i.e. does not echo the session ticket back on reconnect).
1 parent 5bb6b5f commit 5a713f1

1 file changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
# Copyright DataStax, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""
15+
Integration test for TLS session ticket resumption.
16+
17+
Verifies that after the initial TLS handshake learns session tickets, the
18+
driver reuses them on reconnection instead of performing full handshakes.
19+
The test wraps the ``SSLSessionCache`` with a tracking layer that counts
20+
``set()`` calls (each call means a *new* ticket was negotiated). After
21+
closing all connections and waiting for the driver to re-establish them,
22+
the ticket count must remain unchanged — proving that sessions were
23+
resumed, not renegotiated.
24+
25+
Requires a live Scylla / Cassandra cluster with TLS enabled.
26+
"""
27+
28+
import logging
29+
import os
30+
import shutil
31+
import socket
32+
import ssl
33+
import subprocess
34+
import tempfile
35+
import threading
36+
import time
37+
import unittest
38+
39+
from cassandra.connection import SSLSessionCache
40+
41+
from OpenSSL import SSL
42+
43+
from tests import EVENT_LOOP_MANAGER
44+
from tests.integration import (
45+
get_cluster, use_single_node, start_cluster_wait_for_up, TestCluster,
46+
CASSANDRA_IP
47+
)
48+
49+
log = logging.getLogger(__name__)
50+
51+
52+
def _generate_ssl_certs(cert_dir):
53+
"""
54+
Generate a minimal self-signed CA and a server cert/key signed by that CA.
55+
56+
Writes into *cert_dir*:
57+
- ca.key / ca.crt : self-signed CA
58+
- cassandra.key / cassandra.csr / cassandra.crt : server cert
59+
60+
:param cert_dir: directory to write files into (must already exist)
61+
:raises unittest.SkipTest: if ``openssl`` is not on PATH
62+
:raises RuntimeError: if any openssl command fails
63+
"""
64+
if shutil.which("openssl") is None:
65+
raise unittest.SkipTest("openssl not found on PATH; skipping TLS resumption test")
66+
67+
san_cnf = os.path.join(cert_dir, "san.cnf")
68+
with open(san_cnf, "w") as f:
69+
f.write("subjectAltName=IP:127.0.0.1\n")
70+
71+
def _run(cmd):
72+
result = subprocess.run(cmd, cwd=cert_dir, capture_output=True, text=True)
73+
if result.returncode != 0:
74+
raise RuntimeError(
75+
"openssl command failed: %s\n%s" % (" ".join(cmd), result.stderr)
76+
)
77+
78+
_run(["openssl", "req", "-x509", "-newkey", "rsa:2048",
79+
"-keyout", "ca.key", "-out", "ca.crt",
80+
"-days", "1", "-nodes", "-subj", "/CN=Test CA"])
81+
82+
_run(["openssl", "req", "-newkey", "rsa:2048",
83+
"-keyout", "cassandra.key", "-out", "cassandra.csr",
84+
"-nodes", "-subj", "/CN=127.0.0.1"])
85+
86+
_run(["openssl", "x509", "-req",
87+
"-in", "cassandra.csr", "-CA", "ca.crt", "-CAkey", "ca.key",
88+
"-CAcreateserial", "-out", "cassandra.crt",
89+
"-days", "1", "-extfile", "san.cnf"])
90+
91+
log.info("Generated SSL certs in %s", cert_dir)
92+
93+
USES_PYOPENSSL = "twisted" in EVENT_LOOP_MANAGER or "eventlet" in EVENT_LOOP_MANAGER
94+
if "twisted" in EVENT_LOOP_MANAGER:
95+
import OpenSSL
96+
verify_certs = {'cert_reqs': SSL.VERIFY_PEER,
97+
'check_hostname': True}
98+
else:
99+
verify_certs = {'cert_reqs': ssl.CERT_REQUIRED,
100+
'check_hostname': True}
101+
102+
# ---------------------------------------------------------------------------
103+
# Tracking wrapper around SSLSessionCache
104+
# ---------------------------------------------------------------------------
105+
106+
class _TrackingSSLSessionCache(SSLSessionCache):
107+
"""
108+
A thin wrapper around :class:`SSLSessionCache` that counts the number of
109+
full (non-resumed) TLS handshakes by inspecting the ``session_reused``
110+
flag passed to :meth:`set`.
111+
112+
Every ``set()`` call with ``session_reused=False`` corresponds to a fresh
113+
TLS handshake. Calls with ``session_reused=True`` are session updates
114+
after an abbreviated (resumed) handshake and are not counted.
115+
"""
116+
117+
def __init__(self, *args, **kwargs):
118+
super().__init__(*args, **kwargs)
119+
self._ticket_count_lock = threading.Lock()
120+
self._ticket_count = 0
121+
122+
def set(self, key, session):
123+
if session is not None:
124+
with self._ticket_count_lock:
125+
self._ticket_count += 1
126+
super().set(key, session)
127+
128+
@property
129+
def ticket_count(self):
130+
"""Total number of *unique* TLS session tickets stored so far."""
131+
with self._ticket_count_lock:
132+
return self._ticket_count
133+
134+
def _make_ssl_context(ca_cert_path):
135+
"""Return a client ``ssl.SSLContext`` that trusts the test CA at *ca_cert_path*.
136+
137+
TLS 1.2 is explicitly required: Python's ``ssl.SSLSocket.session_reused``
138+
maps to OpenSSL's ``SSL_session_reused()``, which always returns *False*
139+
for TLS 1.3 (PSK resumption is not reflected by that API). Forcing
140+
TLS 1.2 ensures ``session_reused`` is set correctly after a resumed
141+
handshake, which is required by the test's assertion logic.
142+
"""
143+
if USES_PYOPENSSL:
144+
ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD)
145+
ssl_context.load_verify_locations(ca_cert_path)
146+
else:
147+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
148+
ssl_context.load_verify_locations(ca_cert_path)
149+
ssl_context.verify_mode = ssl.CERT_REQUIRED
150+
ssl_context.check_hostname = False
151+
# Restrict to TLS 1.2 so that session_reused is reliable.
152+
ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
153+
return ssl_context
154+
155+
156+
def _server_supports_tls_resumption(ca_cert_path, host, port=9042):
157+
"""
158+
Return ``True`` if the server at *host*:*port* supports TLS 1.2 session
159+
resumption (session-ID or session-ticket based).
160+
161+
Makes two raw TLS 1.2 connections. The second reuses the session from
162+
the first by setting ``ssl.SSLSocket.session`` before calling
163+
``do_handshake()``. Returns ``True`` only when ``session_reused`` is
164+
``True`` on the second connection.
165+
"""
166+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
167+
ctx.load_verify_locations(ca_cert_path)
168+
ctx.check_hostname = False
169+
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
170+
try:
171+
s1 = socket.create_connection((host, port), timeout=5)
172+
ssl1 = ctx.wrap_socket(s1, do_handshake_on_connect=False)
173+
ssl1.do_handshake()
174+
session = ssl1.session
175+
ssl1.close()
176+
if session is None:
177+
return False
178+
time.sleep(0.1)
179+
s2 = socket.create_connection((host, port), timeout=5)
180+
ssl2 = ctx.wrap_socket(s2, do_handshake_on_connect=False)
181+
ssl2.session = session
182+
ssl2.do_handshake()
183+
reused = ssl2.session_reused
184+
ssl2.close()
185+
return bool(reused)
186+
except Exception:
187+
return False
188+
189+
190+
def _wait_for_all_connections(session, timeout=30):
191+
"""
192+
Wait until every host's connection pool is fully connected — i.e., has
193+
at least one live connection per shard (or one connection if the host
194+
has no sharding info), or raise on timeout.
195+
196+
Checking ``len(pool._connections) > 0`` is insufficient for Scylla
197+
clusters: the pool connects the first shard via the standard port, then
198+
immediately fires off shard-aware connections to the remaining shards
199+
asynchronously. Those connections complete in the background and their
200+
TLS sessions are cached only after they finish. Waiting for all shards
201+
ensures the cache is fully populated before we snapshot the ticket count.
202+
"""
203+
deadline = time.monotonic() + timeout
204+
while time.monotonic() < deadline:
205+
pools = session.get_pools()
206+
if pools and all(
207+
not p.is_shutdown and (
208+
len(p._connections) >= (
209+
p.host.sharding_info.shards_count
210+
if p.host.sharding_info else 1
211+
)
212+
)
213+
for p in pools
214+
):
215+
return
216+
time.sleep(0.1)
217+
raise RuntimeError(
218+
"Timed out after %ds waiting for all connection pools to open" % timeout
219+
)
220+
221+
222+
def _close_all_connections(session):
223+
"""
224+
Shut down every connection pool in the session and trigger
225+
re-creation so the driver reconnects to all hosts.
226+
"""
227+
for pool in list(session._pools.values()):
228+
pool.shutdown()
229+
session.update_created_pools()
230+
231+
232+
# ---------------------------------------------------------------------------
233+
# Test
234+
# ---------------------------------------------------------------------------
235+
236+
class TestTLSTicketResumption(unittest.TestCase):
237+
"""
238+
Verify that TLS session tickets are reused across reconnections.
239+
240+
Follows the pattern of the Go driver's ``TestTLSTicketResumption``:
241+
242+
1. Connect to the cluster with a tracking session cache.
243+
2. Record the number of tickets learned.
244+
3. Close all connections, wait for the driver to re-establish them.
245+
4. Assert that **no new tickets** were learned (sessions were resumed).
246+
5. Repeat the close/reconnect cycle once more for confidence.
247+
248+
@test_category connection:ssl:tls_resumption
249+
"""
250+
251+
_cert_dir = None
252+
253+
@classmethod
254+
def setUpClass(cls):
255+
cls._cert_dir = tempfile.mkdtemp(prefix="tls_resumption_certs_")
256+
_generate_ssl_certs(cls._cert_dir)
257+
258+
cls._server_cert_path = os.path.join(cls._cert_dir, "cassandra.crt")
259+
cls._server_key_path = os.path.join(cls._cert_dir, "cassandra.key")
260+
cls._ca_cert_path = os.path.join(cls._cert_dir, "ca.crt")
261+
262+
use_single_node()
263+
ccm_cluster = get_cluster()
264+
ccm_cluster.stop()
265+
266+
config_options = {
267+
'client_encryption_options': {
268+
'enabled': True,
269+
'certificate': cls._server_cert_path,
270+
'keyfile': cls._server_key_path,
271+
'truststore': cls._ca_cert_path,
272+
}
273+
}
274+
ccm_cluster.set_configuration_options(config_options)
275+
start_cluster_wait_for_up(ccm_cluster)
276+
277+
@classmethod
278+
def tearDownClass(cls):
279+
if cls._cert_dir:
280+
shutil.rmtree(cls._cert_dir, ignore_errors=True)
281+
cls._cert_dir = None
282+
283+
def test_tls_ticket_resumption(self):
284+
if not USES_PYOPENSSL and not _server_supports_tls_resumption(
285+
self.__class__._ca_cert_path, CASSANDRA_IP):
286+
self.skipTest(
287+
"Server at %s:9042 does not support TLS session resumption "
288+
"(every connection shows session_reused=False); "
289+
"skipping test" % CASSANDRA_IP
290+
)
291+
292+
cache = _TrackingSSLSessionCache(max_size=1024, ttl=3600)
293+
ssl_context = _make_ssl_context(self.__class__._ca_cert_path)
294+
295+
cluster = TestCluster(
296+
ssl_context=ssl_context,
297+
ssl_session_cache=cache,
298+
)
299+
session = cluster.connect(wait_for_all_pools=True)
300+
try:
301+
_wait_for_all_connections(session)
302+
303+
tickets_after_initial = cache.ticket_count
304+
self.assertGreater(
305+
tickets_after_initial, 0,
306+
"No TLS tickets were learned during initial connection — "
307+
"the server may not support TLS session tickets",
308+
)
309+
log.info("Initial connection: %d ticket(s) learned",
310+
tickets_after_initial)
311+
312+
# ── first reconnection cycle ───────────────────────────────
313+
_close_all_connections(session)
314+
_wait_for_all_connections(session)
315+
316+
tickets_after_reconnect1 = cache.ticket_count
317+
log.info("After 1st reconnect: %d ticket(s) total",
318+
tickets_after_reconnect1)
319+
320+
self.assertEqual(
321+
tickets_after_reconnect1, tickets_after_initial,
322+
"New tickets were learned after 1st reconnect — TLS sessions "
323+
"were NOT reused (got %d, expected %d)"
324+
% (tickets_after_reconnect1, tickets_after_initial),
325+
)
326+
327+
# ── second reconnection cycle ──────────────────────────────
328+
_close_all_connections(session)
329+
_wait_for_all_connections(session)
330+
331+
tickets_after_reconnect2 = cache.ticket_count
332+
log.info("After 2nd reconnect: %d ticket(s) total",
333+
tickets_after_reconnect2)
334+
335+
self.assertEqual(
336+
tickets_after_reconnect2, tickets_after_initial,
337+
"New tickets were learned after 2nd reconnect — TLS sessions "
338+
"were NOT reused (got %d, expected %d)"
339+
% (tickets_after_reconnect2, tickets_after_initial),
340+
)
341+
finally:
342+
cluster.shutdown()

0 commit comments

Comments
 (0)