|
| 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