Add TLS session resumption via SSLSessionCache#789
Conversation
Such claims would ideally be supported by benchmarks. Could you try to create some? |
That's the goal, but you're right, I don't have any tests to prove that, removed this claim from the PR description. If I manage to create proper benchmarks I will update on that |
|
We could, if it helps, only support this for TLS 1.3. |
7281340 to
4500773
Compare
|
@dkropachev @Lorak-mmk I pushed changes with improvement from older Dmitry's PR, will update PR description soon |
dkropachev
left a comment
There was a problem hiding this comment.
I rechecked the TLS session-resumption path against the current branch. The ssl_options configuration still builds a fresh SSLContext per Connection, and a cached stdlib session from the previous connection is incompatible with that new context. I reproduced the failure locally on Python 3.10.12; the session restore path raises ValueError: Session refers to a different SSLContext. Since the new code only catches AttributeError and ssl.SSLError, reconnects fail instead of falling back to a full handshake, and the regression is enabled by default because Cluster auto-creates SSLSessionCache for ssl_options.
4500773 to
d12db4a
Compare
dkropachev
left a comment
There was a problem hiding this comment.
Two blocking issues from local validation:
- Twisted caches a TLS session even after hostname verification has already failed, which lets an untrusted peer populate the resumption cache.
SSLSessionCacheacceptsmax_size <= 0and then crashes on the first insert (KeyErrorfrompopitem()on an emptyOrderedDict).
| transport = connection.get_app_data() | ||
| transport.failVerification(Failure(ConnectionException("Hostname verification failed", self.endpoint))) | ||
| # Store TLS session after successful handshake (PyOpenSSL) | ||
| if self.ssl_session_cache is not None: |
There was a problem hiding this comment.
failVerification() should short-circuit this callback. As written, a hostname mismatch still falls through and caches the just-negotiated session, so an untrusted peer can seed the resumption cache. I reproduced this locally with a mocked _SSLCreator: failVerification was called and the session still landed in SSLSessionCache.
| self._sessions.move_to_end(key) | ||
| return | ||
|
|
||
| if len(self._sessions) >= self._max_size: |
There was a problem hiding this comment.
SSLSessionCache(max_size=0) currently crashes on the first insert: len(self._sessions) >= self._max_size is already true for an empty cache, so popitem(last=False) raises KeyError. Since this is now a public tuning knob, please validate max_size > 0 (and probably ttl > 0) or define zero as a disabled cache, and cover it with a unit test.
d12db4a to
f8eb94d
Compare
dkropachev
left a comment
There was a problem hiding this comment.
Two correctness issues need attention before this lands: the PyOpenSSL TLS 1.3 cache point is too early to capture the resumable session, and the cache can evict a live entry while expired ones remain resident.
08cabfd to
5a713f1
Compare
Introduce SSLSessionCache in connection.py: a thread-safe OrderedDict-based cache with LRU eviction (max_size, default 100) and TTL expiration (default 3600s), keyed by endpoint tls_session_cache_key. Add tls_session_cache_key property to all EndPoint subclasses: - DefaultEndPoint: (address, port) - SniEndPoint: (address, port, server_name) — prevents proxy collisions - UnixSocketEndPoint: (unix_socket_path,) - ClientRoutesEndPoint: (host_id, address, port) Includes unit tests for basic ops, key isolation, SNI keys, overwrite, thread safety, TTL expiration, LRU eviction, clear/clear_expired, automatic cleanup, custom parameters, and endpoint cache key tests.
- Add _ssl_session_cache attribute on Connection, set via ssl_session_cache param - Restore cached TLS sessions in _wrap_socket_from_context with error tolerance - Add _cache_tls_session_if_needed helper (delegates to endpoint.tls_session_cache_key) - Cache sessions at 3 points: after connect, ReadyMessage, AuthSuccessMessage (handles TLS 1.3 async ticket delivery) - Add TestConnectionSSLSessionRestore and TestConnectionCacheTLSSession tests
5a713f1 to
61f7523
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds a thread-safe LRU/TTL Sequence Diagram(s)sequenceDiagram
participant Cluster
participant Connection
participant SSLSessionCache
participant TLSReactor
Cluster->>Connection: create with resolved ssl_session_cache
Connection->>SSLSessionCache: retrieve endpoint session
SSLSessionCache-->>Connection: cached session
Connection->>TLSReactor: restore session before handshake
TLSReactor-->>Connection: complete TLS handshake
Connection->>SSLSessionCache: store negotiated session
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/integration/standard/test_tls_resumption.py (1)
156-187: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueBroad exception swallowing could mask real setup errors as "resumption unsupported".
Catching bare
Exceptionand returningFalsemeans genuine connectivity/TLS-config errors (bad cert path, wrong port, etc.) silently produce a test skip instead of surfacing as a failure.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/integration/standard/test_tls_resumption.py` around lines 156 - 187, Update _server_supports_tls_resumption to stop catching all Exception instances and converting setup or connection failures into False. Restrict the handled exceptions to the expected “TLS resumption unsupported” cases, while allowing invalid certificate paths, connectivity failures, and other configuration errors to propagate as test failures; preserve False for a valid handshake where no session is produced or the second session is not reused.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/integration/standard/test_tls_resumption.py`:
- Around line 134-153: Update _make_ssl_context so the USES_PYOPENSSL branch
also caps the created context at TLS 1.2 by setting its maximum protocol version
to SSL.TLS1_2_VERSION, matching the stdlib branch while leaving the certificate
setup unchanged.
---
Nitpick comments:
In `@tests/integration/standard/test_tls_resumption.py`:
- Around line 156-187: Update _server_supports_tls_resumption to stop catching
all Exception instances and converting setup or connection failures into False.
Restrict the handled exceptions to the expected “TLS resumption unsupported”
cases, while allowing invalid certificate paths, connectivity failures, and
other configuration errors to propagate as test failures; preserve False for a
valid handshake where no session is produced or the second session is not
reused.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 49e185b2-4dea-4614-baa7-96ec644f5534
📒 Files selected for processing (8)
cassandra/cluster.pycassandra/connection.pycassandra/io/eventletreactor.pycassandra/io/twistedreactor.pytests/integration/standard/test_tls_resumption.pytests/unit/io/test_twistedreactor.pytests/unit/test_cluster.pytests/unit/test_connection.py
| def _make_ssl_context(ca_cert_path): | ||
| """Return a client ``ssl.SSLContext`` that trusts the test CA at *ca_cert_path*. | ||
|
|
||
| TLS 1.2 is explicitly required: Python's ``ssl.SSLSocket.session_reused`` | ||
| maps to OpenSSL's ``SSL_session_reused()``, which always returns *False* | ||
| for TLS 1.3 (PSK resumption is not reflected by that API). Forcing | ||
| TLS 1.2 ensures ``session_reused`` is set correctly after a resumed | ||
| handshake, which is required by the test's assertion logic. | ||
| """ | ||
| if USES_PYOPENSSL: | ||
| ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) | ||
| ssl_context.load_verify_locations(ca_cert_path) | ||
| else: | ||
| ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | ||
| ssl_context.load_verify_locations(ca_cert_path) | ||
| ssl_context.verify_mode = ssl.CERT_REQUIRED | ||
| ssl_context.check_hostname = False | ||
| # Restrict to TLS 1.2 so that session_reused is reliable. | ||
| ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2 | ||
| return ssl_context |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
rg -n "pyOpenSSL|pyopenssl|OpenSSL" -S .Repository: scylladb/python-driver
Length of output: 5629
🏁 Script executed:
sed -n '1,340p' tests/integration/standard/test_tls_resumption.pyRepository: scylladb/python-driver
Length of output: 12859
🏁 Script executed:
sed -n '180,330p' cassandra/io/twistedreactor.py && printf '\n---\n' && sed -n '1,220p' cassandra/io/eventletreactor.py && printf '\n---\n' && rg -n "pyOpenSSL|pyopenssl|pyopenssl|OpenSSL|cryptography" -S requirements* setup* pyproject.toml tox.ini constraints* .Repository: scylladb/python-driver
Length of output: 21028
🌐 Web query:
pyOpenSSL Context set_max_proto_version TLS1_2_VERSION API docs
💡 Result:
In pyOpenSSL, the method set_max_proto_version is available on Context objects to configure the maximum supported TLS protocol version [1][2][3]. To restrict the maximum protocol version to TLS 1.2, you should pass the constant OpenSSL.SSL.TLS1_2_VERSION to this method [1][2]. Usage example: context = OpenSSL.SSL.Context(OpenSSL.SSL.TLS_METHOD) context.set_max_proto_version(OpenSSL.SSL.TLS1_2_VERSION) Key points regarding this API: 1. Availability: This method was added to provide explicit control over supported TLS versions, replacing older, deprecated protocol-specific methods (such as TLSv1_2_METHOD) [4][5][3]. 2. Functionality: Setting the maximum version to 0 (the default behavior if not restricted) allows the library to negotiate up to the highest version supported by the underlying OpenSSL build [1][2]. 3. Error Handling: If the underlying OpenSSL library does not support the version requested, the method will raise an exception [1][2]. Official documentation can be found in the pyOpenSSL SSL module API reference under Context objects [6][1][2].
Citations:
- 1: https://www.pyopenssl.org/en/24.3.0/api/ssl.html
- 2: https://www.pyopenssl.org/en/24.0.0/api/ssl.html
- 3: https://www.pyopenssl.org/en/latest/changelog.html
- 4: https://checklist.day/registry/pyopenssl
- 5: pyca/pyopenssl@5dc6988
- 6: https://www.pyopenssl.org/en/latest/api/ssl.html
Cap the PyOpenSSL context at TLS 1.2
_make_ssl_context only restricts the stdlib branch today. Add the same TLS 1.2 cap to the USES_PYOPENSSL branch (ssl_context.set_max_proto_version(SSL.TLS1_2_VERSION)) so session_reused() stays meaningful for this test under Twisted/Eventlet.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/integration/standard/test_tls_resumption.py` around lines 134 - 153,
Update _make_ssl_context so the USES_PYOPENSSL branch also caps the created
context at TLS 1.2 by setting its maximum protocol version to
SSL.TLS1_2_VERSION, matching the stdlib branch while leaving the certificate
setup unchanged.
61f7523 to
2071853
Compare
| @classmethod | ||
| def tearDownClass(cls): | ||
| if cls._cert_dir: | ||
| shutil.rmtree(cls._cert_dir, ignore_errors=True) |
There was a problem hiding this comment.
setUpClass enables client_encryption_options on the shared single_node CCM cluster, but teardown only deletes the generated certs. Later standard tests that reuse use_single_node() will inherit TLS config and connect without SSL, and any restart will point Scylla at deleted certificate files. Please restore the CCM TLS configuration/restart, or remove the cluster, before deleting the cert directory.
| sessions are cached after the CQL handshake completes (Ready / AuthSuccess), | ||
| because session tickets are sent asynchronously by the server. | ||
|
|
||
| Works with all connection classes: stdlib ``ssl`` (asyncore, libev, gevent, |
There was a problem hiding this comment.
This advertises asyncio support, but AsyncioConnection does not use the shared TLS cache path. It overrides _connect_socket() so self._socket stays plain, then performs TLS through loop.create_connection(..., ssl=ssl_context). Nothing restores a cached session before the handshake or stores transport.get_extra_info("ssl_object").session afterward, so EVENT_LOOP_MANAGER=asyncio never populates or uses the cache. Please wire the asyncio reactor into session restore/store, or exclude it from the advertised support.
There was a problem hiding this comment.
I excluded it from the advertised support
- Import SSLSessionCache in cluster.py - Add ssl_session_cache attribute with comprehensive docstring - Add ssl_session_cache parameter to Cluster.__init__ (default _NOT_SET) - Auto-create SSLSessionCache when ssl_context or ssl_options are set - Pass ssl_session_cache to connection factory via _make_connection_kwargs - Add TestSSLSessionCacheAutoCreation tests (6 tests)
- EventletConnection: restore cached session before handshake via set_session() - TwistedConnection: pass ssl_session_cache to _SSLCreator, restore cached session in clientConnectionForTLS() - Both reactors: defer session storage to _cache_tls_session_if_needed() override called at ReadyMessage / AuthSuccessMessage time, ensuring TLS 1.3 session tickets (which arrive after the first application-data exchange) are captured - Skip caching when session_reused() is True (abbreviated handshake) - All operations wrapped in try/except for error tolerance - Debug logging for session reuse and restore/store failures
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).
2071853 to
03af527
Compare
|
CI failures unrelated to these PR, they are due to #931 |
| # For TLS 1.2 the session is already available at handshake completion; cache | ||
| # it immediately. For TLS 1.3 get_session() returns None here and the deferred | ||
| # path in TwistedConnection._cache_tls_session_if_needed() handles it instead. | ||
| if self.ssl_session_cache is not None and not connection.session_reused(): |
There was a problem hiding this comment.
Current pyOpenSSL OpenSSL.SSL.Connection does not expose session_reused(), only get_session()/set_session(). This raises from the handshake info callback when Twisted TLS is used with the auto-created cache, so the session never gets cached and this reactor cannot actually resume TLS sessions.
| if self._ssl_session_cache is None or not (self.ssl_context or self.ssl_options): | ||
| return | ||
| try: | ||
| if self._socket.session_reused(): |
There was a problem hiding this comment.
This has the same PyOpenSSL API problem: the green SSL.Connection exposes get_session() but not session_reused(). The resulting AttributeError is swallowed by the broad except, so we never reach get_session() and the Eventlet session cache remains empty.
Summary
This PR implements TLS session resumption for the Python driver. After the first
successful TLS handshake with a node, the negotiated session is stored in a
thread-safe cache and reused on subsequent connections, skipping the full
handshake.
Both TLS 1.2 (session IDs) and TLS 1.3 (session tickets / PSK) are supported.
Changes
cassandra/connection.py—SSLSessionCacheclass & endpoint keys_SessionCacheEntrynamedtuple stores(session, timestamp)for TTL tracking.SSLSessionCache: a thread-safeOrderedDict-based cache with LRU eviction,TTL expiration, and periodic cleanup (every 100
set()calls), keyed byendpoint
tls_session_cache_key.max_size(default 100) andttl(default 3600 s).EndPointclass provides a defaulttls_session_cache_keypropertyreturning
(address, port). Subclasses override for context-specific keys:DefaultEndPoint:(address, port)— inherits defaultSniEndPoint:(address, port, server_name)— prevents proxy collisionsUnixSocketEndPoint:(unix_socket_path,)ClientRoutesEndPoint:(host_id, address, port)cassandra/connection.py—ConnectionwiringConnectiongains_ssl_session_cacheattribute, set viassl_session_cachekwarg in
__init__._wrap_socket_from_context()restores a cached session viassl_sock.session = ...afterwrap_socket(); gracefully handlesssl.SSLError/AttributeErrorif the server rejects the session._ssl_session_cache_key()helper delegates toendpoint.tls_session_cache_key._cache_tls_session_if_needed()storessocket.sessionin the cache whenssl_contextis set and the session is non-None._initiate_connection()in_connect_socket()— TLS 1.2 sessionsare available immediately after connect.
ReadyMessagein_handle_startup_response()— TLS 1.3 ticketsarrive asynchronously after the first application-data exchange.
AuthSuccessMessagein_handle_auth_response()— same TLS 1.3coverage for authenticated connections.
cassandra/cluster.py—ClusterintegrationSSLSessionCache.ssl_session_cacheclass attribute with docstring.__init__acceptsssl_session_cache=_NOT_SETparameter.SSLSessionCache()whenssl_contextorssl_optionsareset; no configuration required for the common case.
ssl_session_cache=Noneexplicitly to opt out.SSLSessionCache(max_size=…, ttl=…)can be supplied._make_connection_kwargs()passes the cache to everyConnectionviakwargs_dict.setdefault('ssl_session_cache', self.ssl_session_cache).cassandra/io/eventletreactor.py— Eventlet (PyOpenSSL) support_wrap_socket_from_context()restores cached PyOpenSSL sessions viaset_session()before the handshake._initiate_connection()calls_cache_pyopenssl_session()afterdo_handshake()._cache_pyopenssl_session()helper stores the session viaget_session(), logs whether the session was reused(
session_reused()), and catches all exceptions silently.cassandra/io/twistedreactor.py— Twisted (PyOpenSSL) support_SSLCreator.__init__accepts an optionalssl_session_cacheparameter.clientConnectionForTLS()restores cached sessions viaset_session().info_callback()stores sessions afterSSL_CB_HANDSHAKE_DONEviaget_session(), logs reuse status.TwistedConnection.add_connection()passesssl_session_cache=self._ssl_session_cacheto
_SSLCreator.Tests
tests/unit/test_connection.pyTestSSLSessionCache— empty lookup, set/get, key isolation byaddress/port/SNI, overwrite, thread safety, TTL expiration, LRU eviction,
max_size enforcement,
clear(),clear_expired(), automatic periodiccleanup,
Nonesession handling.TestEndPointTLSSessionCacheKey— cache key correctness forDefaultEndPoint,SniEndPoint,UnixSocketEndPoint,ClientRoutesEndPoint, plus isolation between different paths/addresses.TestConnectionSSLSessionRestore— session restore from cache,tolerance when cache is
None,ssl.SSLErroronsessionsetter,SNI-specific cached session lookup.
TestConnectionCacheTLSSession— session stored after connect,no-op when
session=None, no-op whencache=None, no-op whenssl_context=None, SNI-specific key used for storage.tests/unit/test_cluster.pyTestSSLSessionCacheAutoCreation— auto-create withssl_context,auto-create with
ssl_options, no cache without TLS, explicitNoneopt-out, custom cache injection, cache passed to
connection_factory.Fixes: https://scylladb.atlassian.net/browse/DRIVER-165
Pre-review checklist
./docs/source/.Fixes:annotations to PR description.