Skip to content

Commit 5bb6b5f

Browse files
Add PyOpenSSL TLS session caching for Eventlet and Twisted
- 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
1 parent 6c367b8 commit 5bb6b5f

3 files changed

Lines changed: 202 additions & 3 deletions

File tree

cassandra/io/eventletreactor.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ def _wrap_socket_from_context(self):
108108
if self.ssl_options and 'server_hostname' in self.ssl_options:
109109
# This is necessary for SNI
110110
self._socket.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
111+
# Apply cached TLS session for resumption (PyOpenSSL)
112+
if self._ssl_session_cache is not None:
113+
cached_session = self._ssl_session_cache.get(
114+
self._ssl_session_cache_key())
115+
if cached_session is not None:
116+
try:
117+
self._socket.set_session(cached_session)
118+
log.debug("Using cached TLS session for %s", self.endpoint)
119+
except Exception:
120+
log.debug("Could not restore TLS session for %s", self.endpoint)
121+
122+
# Return the SSL.Connection so that the base-class _connect_socket() can
123+
# store it in self._socket via: self._socket = self._wrap_socket_from_context()
124+
# Without this return the assignment would overwrite self._socket with None,
125+
# breaking every subsequent call on the socket.
126+
return self._socket
111127

112128
def _initiate_connection(self, sockaddr):
113129
if self.uses_legacy_ssl_options:
@@ -116,6 +132,11 @@ def _initiate_connection(self, sockaddr):
116132
self._socket.connect(sockaddr)
117133
if self.ssl_context or self.ssl_options:
118134
self._socket.do_handshake()
135+
# No early session caching here. _cache_tls_session_if_needed() is
136+
# called by the base class at ReadyMessage / AuthSuccessMessage time,
137+
# which is the correct point for both TLS 1.2 and TLS 1.3 (for TLS 1.3
138+
# the resumable session ticket is only available after the first
139+
# application-data exchange).
119140

120141
def _match_hostname(self):
121142
if self.uses_legacy_ssl_options:
@@ -126,6 +147,35 @@ def _match_hostname(self):
126147
raise Exception("Hostname verification failed! Certificate name '{}' "
127148
"doesn't endpoint '{}'".format(cert_name, self.endpoint.address))
128149

150+
def _cache_tls_session_if_needed(self):
151+
"""
152+
PyOpenSSL override of :meth:`.Connection._cache_tls_session_if_needed`.
153+
154+
Uses ``SSL.Connection.get_session()`` / ``session_reused()`` instead of
155+
the stdlib ``ssl.SSLSocket.session`` / ``session_reused`` attributes.
156+
Called by the base class at ReadyMessage / AuthSuccessMessage time so
157+
that TLS 1.3 session tickets — which arrive after the first
158+
application-data exchange — are captured correctly.
159+
160+
Falls back to the base-class implementation for the legacy
161+
``ssl_options``-only path, which uses a stdlib ``ssl.SSLSocket``.
162+
"""
163+
if self.uses_legacy_ssl_options:
164+
super(EventletConnection, self)._cache_tls_session_if_needed()
165+
return
166+
if self._ssl_session_cache is None or not (self.ssl_context or self.ssl_options):
167+
return
168+
try:
169+
if self._socket.session_reused():
170+
log.debug("TLS session was reused for %s", self.endpoint)
171+
else:
172+
session = self._socket.get_session()
173+
if session is not None:
174+
self._ssl_session_cache.set(
175+
self._ssl_session_cache_key(), session)
176+
except Exception:
177+
log.debug("Could not cache TLS session for %s", self.endpoint)
178+
129179
def close(self):
130180
with self.lock:
131181
if self.is_closed:

cassandra/io/twistedreactor.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,15 @@ def _on_loop_timer(self):
139139

140140
@implementer(IOpenSSLClientConnectionCreator)
141141
class _SSLCreator(object):
142-
def __init__(self, endpoint, ssl_context, ssl_options, check_hostname, timeout):
142+
def __init__(self, endpoint, ssl_context, ssl_options, check_hostname, timeout, ssl_session_cache=None):
143143
self.endpoint = endpoint
144144
self.ssl_options = ssl_options
145145
self.check_hostname = check_hostname
146146
self.timeout = timeout
147+
self.ssl_session_cache = ssl_session_cache
148+
# Populated by info_callback after every successful handshake; read by
149+
# TwistedConnection._cache_tls_session_if_needed() after the first CQL exchange.
150+
self._ssl_connection = None
147151

148152
if ssl_context:
149153
self.context = ssl_context
@@ -170,12 +174,41 @@ def info_callback(self, connection, where, ret):
170174
if self.check_hostname and self.endpoint.address != connection.get_peer_certificate().get_subject().commonName:
171175
transport = connection.get_app_data()
172176
transport.failVerification(Failure(ConnectionException("Hostname verification failed", self.endpoint)))
177+
return
178+
# Store the live SSL.Connection so that TwistedConnection._cache_tls_session_if_needed()
179+
# can read the final session after the first CQL exchange. Session caching is
180+
# deliberately deferred to that point: for TLS 1.3 the resumable session ticket
181+
# is only available after the first application-data record, which arrives with
182+
# the CQL ReadyMessage or AuthSuccessMessage.
183+
self._ssl_connection = connection
184+
# For TLS 1.2 the session is already available at handshake completion; cache
185+
# it immediately. For TLS 1.3 get_session() returns None here and the deferred
186+
# path in TwistedConnection._cache_tls_session_if_needed() handles it instead.
187+
if self.ssl_session_cache is not None and not connection.session_reused():
188+
session = connection.get_session()
189+
if session is not None:
190+
self.ssl_session_cache.set(self.endpoint.tls_session_cache_key, session)
173191

174192
def clientConnectionForTLS(self, tlsProtocol):
193+
# Reset any reference from a previous connection attempt on this creator.
194+
self._ssl_connection = None
195+
175196
connection = SSL.Connection(self.context, None)
176197
connection.set_app_data(tlsProtocol)
177198
if self.ssl_options and "server_hostname" in self.ssl_options:
178199
connection.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
200+
201+
# Apply cached TLS session for resumption (PyOpenSSL)
202+
if self.ssl_session_cache is not None:
203+
cached_session = self.ssl_session_cache.get(
204+
self.endpoint.tls_session_cache_key)
205+
if cached_session:
206+
try:
207+
connection.set_session(cached_session)
208+
log.debug("Using cached TLS session for %s", self.endpoint)
209+
except Exception:
210+
log.debug("Could not restore TLS session for %s", self.endpoint)
211+
179212
return connection
180213

181214

@@ -212,6 +245,7 @@ def __init__(self, *args, **kwargs):
212245
self.is_closed = True
213246
self.connector = None
214247
self.transport = None
248+
self._ssl_creator = None # set in add_connection() when SSL is used
215249

216250
reactor.callFromThread(self.add_connection)
217251
self._loop.maybe_start()
@@ -241,7 +275,9 @@ def add_connection(self):
241275
self.ssl_options,
242276
self._check_hostname,
243277
self.connect_timeout,
278+
ssl_session_cache=self._ssl_session_cache,
244279
)
280+
self._ssl_creator = ssl_connection_creator
245281

246282
endpoint = SSL4ClientEndpoint(
247283
reactor,
@@ -259,6 +295,31 @@ def add_connection(self):
259295
)
260296
connectProtocol(endpoint, TwistedConnectionProtocol(self))
261297

298+
def _cache_tls_session_if_needed(self):
299+
"""
300+
PyOpenSSL override of :meth:`.Connection._cache_tls_session_if_needed`.
301+
302+
Called by the base class at ReadyMessage / AuthSuccessMessage time —
303+
after the first application-data exchange. For TLS 1.3 this is the
304+
earliest point at which the resumable session ticket is available;
305+
for TLS 1.2 the session is also valid here.
306+
"""
307+
if self._ssl_session_cache is None or self._ssl_creator is None:
308+
return
309+
ssl_conn = self._ssl_creator._ssl_connection
310+
if ssl_conn is None:
311+
return
312+
try:
313+
if ssl_conn.session_reused():
314+
log.debug("TLS session was reused for %s", self.endpoint)
315+
else:
316+
session = ssl_conn.get_session()
317+
if session is not None:
318+
self._ssl_session_cache.set(
319+
self.endpoint.tls_session_cache_key, session)
320+
except Exception:
321+
log.debug("Could not cache TLS session for %s", self.endpoint)
322+
262323
def client_connection_made(self, transport):
263324
"""
264325
Called by twisted protocol when a connection attempt has

tests/unit/io/test_twistedreactor.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
# limitations under the License.
1414

1515
import unittest
16-
from unittest.mock import Mock, patch
16+
from unittest.mock import Mock, patch, MagicMock
1717

18-
from cassandra.connection import DefaultEndPoint
18+
from cassandra.connection import DefaultEndPoint, SSLSessionCache
1919

2020
try:
2121
from twisted.test import proto_helpers
@@ -197,3 +197,91 @@ def test_push(self, mock_connectTCP):
197197
self.obj_ut.push('123 pickup')
198198
self.mock_reactor_cft.assert_called_with(
199199
transport_mock.write, '123 pickup')
200+
201+
202+
class TestSSLCreatorInfoCallback(unittest.TestCase):
203+
"""Verify that _SSLCreator.info_callback does not cache TLS sessions
204+
when hostname verification fails."""
205+
206+
def setUp(self):
207+
if twistedreactor is None:
208+
raise unittest.SkipTest("Twisted libraries not available")
209+
from OpenSSL import SSL
210+
self.SSL = SSL
211+
212+
def _make_creator(self, check_hostname, endpoint_address, cert_cn,
213+
ssl_session_cache=None):
214+
from cassandra.io.twistedreactor import _SSLCreator
215+
endpoint = Mock()
216+
endpoint.address = endpoint_address
217+
endpoint.tls_session_cache_key = (endpoint_address, 9042)
218+
ssl_ctx = Mock()
219+
creator = _SSLCreator(
220+
endpoint=endpoint,
221+
ssl_context=ssl_ctx,
222+
ssl_options=None,
223+
check_hostname=check_hostname,
224+
timeout=5,
225+
ssl_session_cache=ssl_session_cache,
226+
)
227+
228+
# Build a mock OpenSSL connection
229+
connection = Mock()
230+
subject = Mock()
231+
subject.commonName = cert_cn
232+
cert = Mock()
233+
cert.get_subject.return_value = subject
234+
connection.get_peer_certificate.return_value = cert
235+
session = Mock()
236+
connection.get_session.return_value = session
237+
connection.session_reused.return_value = False
238+
transport = Mock()
239+
connection.get_app_data.return_value = transport
240+
241+
return creator, connection, transport, session
242+
243+
def test_hostname_mismatch_does_not_cache(self):
244+
"""When hostname verification fails, the session must NOT be cached."""
245+
cache = SSLSessionCache()
246+
creator, connection, transport, session = self._make_creator(
247+
check_hostname=True,
248+
endpoint_address='good.example.com',
249+
cert_cn='evil.example.com',
250+
ssl_session_cache=cache,
251+
)
252+
253+
creator.info_callback(connection, self.SSL.SSL_CB_HANDSHAKE_DONE, 0)
254+
255+
transport.failVerification.assert_called_once()
256+
assert cache.size() == 0, "Session was cached despite hostname mismatch"
257+
258+
def test_hostname_match_caches_session(self):
259+
"""When hostname matches, the session should be cached."""
260+
cache = SSLSessionCache()
261+
creator, connection, transport, session = self._make_creator(
262+
check_hostname=True,
263+
endpoint_address='good.example.com',
264+
cert_cn='good.example.com',
265+
ssl_session_cache=cache,
266+
)
267+
268+
creator.info_callback(connection, self.SSL.SSL_CB_HANDSHAKE_DONE, 0)
269+
270+
transport.failVerification.assert_not_called()
271+
assert cache.size() == 1
272+
assert cache.get(('good.example.com', 9042)) is session
273+
274+
def test_no_check_hostname_caches_session(self):
275+
"""When check_hostname is False, always cache regardless of CN."""
276+
cache = SSLSessionCache()
277+
creator, connection, transport, session = self._make_creator(
278+
check_hostname=False,
279+
endpoint_address='good.example.com',
280+
cert_cn='evil.example.com',
281+
ssl_session_cache=cache,
282+
)
283+
284+
creator.info_callback(connection, self.SSL.SSL_CB_HANDSHAKE_DONE, 0)
285+
286+
transport.failVerification.assert_not_called()
287+
assert cache.size() == 1

0 commit comments

Comments
 (0)