Skip to content

Commit 99a6570

Browse files
committed
Issue #19500: Add client-side SSL session resumption to the ssl module.
1 parent d048637 commit 99a6570

File tree

5 files changed

+582
-20
lines changed

5 files changed

+582
-20
lines changed

Doc/library/ssl.rst

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,10 @@ Constants
776776

777777
:class:`enum.IntFlag` collection of OP_* constants.
778778

779+
.. data:: OP_NO_TICKET
780+
781+
Prevent client side from requesting a session ticket.
782+
779783
.. versionadded:: 3.6
780784

781785
.. data:: HAS_ALPN
@@ -1176,6 +1180,19 @@ SSL sockets also have the following additional methods and attributes:
11761180

11771181
.. versionadded:: 3.2
11781182

1183+
.. attribute:: SSLSocket.session
1184+
1185+
The :class:`SSLSession` for this SSL connection. The session is available
1186+
for client and server side sockets after the TLS handshake has been
1187+
performed. For client sockets the session can be set before
1188+
:meth:`~SSLSocket.do_handshake` has been called to reuse a session.
1189+
1190+
.. versionadded:: 3.6
1191+
1192+
.. attribute:: SSLSocket.session_reused
1193+
1194+
.. versionadded:: 3.6
1195+
11791196

11801197
SSL Contexts
11811198
------------
@@ -1509,7 +1526,7 @@ to speed up repeated connections from the same clients.
15091526

15101527
.. method:: SSLContext.wrap_socket(sock, server_side=False, \
15111528
do_handshake_on_connect=True, suppress_ragged_eofs=True, \
1512-
server_hostname=None)
1529+
server_hostname=None, session=None)
15131530

15141531
Wrap an existing Python socket *sock* and return an :class:`SSLSocket`
15151532
object. *sock* must be a :data:`~socket.SOCK_STREAM` socket; other socket
@@ -1526,19 +1543,27 @@ to speed up repeated connections from the same clients.
15261543
quite similarly to HTTP virtual hosts. Specifying *server_hostname* will
15271544
raise a :exc:`ValueError` if *server_side* is true.
15281545

1546+
*session*, see :attr:`~SSLSocket.session`.
1547+
15291548
.. versionchanged:: 3.5
15301549
Always allow a server_hostname to be passed, even if OpenSSL does not
15311550
have SNI.
15321551

1552+
.. versionchanged:: 3.6
1553+
*session* argument was added.
1554+
15331555
.. method:: SSLContext.wrap_bio(incoming, outgoing, server_side=False, \
1534-
server_hostname=None)
1556+
server_hostname=None, session=None)
15351557

15361558
Create a new :class:`SSLObject` instance by wrapping the BIO objects
15371559
*incoming* and *outgoing*. The SSL routines will read input data from the
15381560
incoming BIO and write data to the outgoing BIO.
15391561

1540-
The *server_side* and *server_hostname* parameters have the same meaning as
1541-
in :meth:`SSLContext.wrap_socket`.
1562+
The *server_side*, *server_hostname* and *session* parameters have the
1563+
same meaning as in :meth:`SSLContext.wrap_socket`.
1564+
1565+
.. versionchanged:: 3.6
1566+
*session* argument was added.
15421567

15431568
.. method:: SSLContext.session_stats()
15441569

@@ -2045,6 +2070,8 @@ provided.
20452070
- :attr:`~SSLSocket.context`
20462071
- :attr:`~SSLSocket.server_side`
20472072
- :attr:`~SSLSocket.server_hostname`
2073+
- :attr:`~SSLSocket.session`
2074+
- :attr:`~SSLSocket.session_reused`
20482075
- :meth:`~SSLSocket.read`
20492076
- :meth:`~SSLSocket.write`
20502077
- :meth:`~SSLSocket.getpeercert`
@@ -2126,6 +2153,22 @@ purpose. It wraps an OpenSSL memory BIO (Basic IO) object:
21262153
become true after all data currently in the buffer has been read.
21272154

21282155

2156+
SSL session
2157+
-----------
2158+
2159+
.. versionadded:: 3.6
2160+
2161+
.. class:: SSLSession
2162+
2163+
Session object used by :attr:`~SSLSocket.session`.
2164+
2165+
.. attribute:: id
2166+
.. attribute:: time
2167+
.. attribute:: timeout
2168+
.. attribute:: ticket_lifetime_hint
2169+
.. attribute:: has_ticket
2170+
2171+
21292172
.. _ssl-security:
21302173

21312174
Security considerations

Lib/ssl.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
import _ssl # if we can't import it, let the error propagate
100100

101101
from _ssl import OPENSSL_VERSION_NUMBER, OPENSSL_VERSION_INFO, OPENSSL_VERSION
102-
from _ssl import _SSLContext, MemoryBIO
102+
from _ssl import _SSLContext, MemoryBIO, SSLSession
103103
from _ssl import (
104104
SSLError, SSLZeroReturnError, SSLWantReadError, SSLWantWriteError,
105105
SSLSyscallError, SSLEOFError,
@@ -391,18 +391,18 @@ def __init__(self, protocol=PROTOCOL_TLS):
391391
def wrap_socket(self, sock, server_side=False,
392392
do_handshake_on_connect=True,
393393
suppress_ragged_eofs=True,
394-
server_hostname=None):
394+
server_hostname=None, session=None):
395395
return SSLSocket(sock=sock, server_side=server_side,
396396
do_handshake_on_connect=do_handshake_on_connect,
397397
suppress_ragged_eofs=suppress_ragged_eofs,
398398
server_hostname=server_hostname,
399-
_context=self)
399+
_context=self, _session=session)
400400

401401
def wrap_bio(self, incoming, outgoing, server_side=False,
402-
server_hostname=None):
402+
server_hostname=None, session=None):
403403
sslobj = self._wrap_bio(incoming, outgoing, server_side=server_side,
404404
server_hostname=server_hostname)
405-
return SSLObject(sslobj)
405+
return SSLObject(sslobj, session=session)
406406

407407
def set_npn_protocols(self, npn_protocols):
408408
protos = bytearray()
@@ -572,10 +572,12 @@ class SSLObject:
572572
* The ``do_handshake_on_connect`` and ``suppress_ragged_eofs`` machinery.
573573
"""
574574

575-
def __init__(self, sslobj, owner=None):
575+
def __init__(self, sslobj, owner=None, session=None):
576576
self._sslobj = sslobj
577577
# Note: _sslobj takes a weak reference to owner
578578
self._sslobj.owner = owner or self
579+
if session is not None:
580+
self._sslobj.session = session
579581

580582
@property
581583
def context(self):
@@ -586,6 +588,20 @@ def context(self):
586588
def context(self, ctx):
587589
self._sslobj.context = ctx
588590

591+
@property
592+
def session(self):
593+
"""The SSLSession for client socket."""
594+
return self._sslobj.session
595+
596+
@session.setter
597+
def session(self, session):
598+
self._sslobj.session = session
599+
600+
@property
601+
def session_reused(self):
602+
"""Was the client session reused during handshake"""
603+
return self._sslobj.session_reused
604+
589605
@property
590606
def server_side(self):
591607
"""Whether this is a server-side socket."""
@@ -703,7 +719,7 @@ def __init__(self, sock=None, keyfile=None, certfile=None,
703719
family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None,
704720
suppress_ragged_eofs=True, npn_protocols=None, ciphers=None,
705721
server_hostname=None,
706-
_context=None):
722+
_context=None, _session=None):
707723

708724
if _context:
709725
self._context = _context
@@ -735,11 +751,16 @@ def __init__(self, sock=None, keyfile=None, certfile=None,
735751
# mixed in.
736752
if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM:
737753
raise NotImplementedError("only stream sockets are supported")
738-
if server_side and server_hostname:
739-
raise ValueError("server_hostname can only be specified "
740-
"in client mode")
754+
if server_side:
755+
if server_hostname:
756+
raise ValueError("server_hostname can only be specified "
757+
"in client mode")
758+
if _session is not None:
759+
raise ValueError("session can only be specified in "
760+
"client mode")
741761
if self._context.check_hostname and not server_hostname:
742762
raise ValueError("check_hostname requires server_hostname")
763+
self._session = _session
743764
self.server_side = server_side
744765
self.server_hostname = server_hostname
745766
self.do_handshake_on_connect = do_handshake_on_connect
@@ -775,7 +796,8 @@ def __init__(self, sock=None, keyfile=None, certfile=None,
775796
try:
776797
sslobj = self._context._wrap_socket(self, server_side,
777798
server_hostname)
778-
self._sslobj = SSLObject(sslobj, owner=self)
799+
self._sslobj = SSLObject(sslobj, owner=self,
800+
session=self._session)
779801
if do_handshake_on_connect:
780802
timeout = self.gettimeout()
781803
if timeout == 0.0:
@@ -796,6 +818,24 @@ def context(self, ctx):
796818
self._context = ctx
797819
self._sslobj.context = ctx
798820

821+
@property
822+
def session(self):
823+
"""The SSLSession for client socket."""
824+
if self._sslobj is not None:
825+
return self._sslobj.session
826+
827+
@session.setter
828+
def session(self, session):
829+
self._session = session
830+
if self._sslobj is not None:
831+
self._sslobj.session = session
832+
833+
@property
834+
def session_reused(self):
835+
"""Was the client session reused during handshake"""
836+
if self._sslobj is not None:
837+
return self._sslobj.session_reused
838+
799839
def dup(self):
800840
raise NotImplemented("Can't dup() %s instances" %
801841
self.__class__.__name__)
@@ -1028,7 +1068,8 @@ def _real_connect(self, addr, connect_ex):
10281068
if self._connected:
10291069
raise ValueError("attempt to connect already-connected SSLSocket!")
10301070
sslobj = self.context._wrap_socket(self, False, self.server_hostname)
1031-
self._sslobj = SSLObject(sslobj, owner=self)
1071+
self._sslobj = SSLObject(sslobj, owner=self,
1072+
session=self._session)
10321073
try:
10331074
if connect_ex:
10341075
rc = socket.connect_ex(self, addr)

Lib/test/test_ssl.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@ def stop(self):
21632163
self.server.close()
21642164

21652165
def server_params_test(client_context, server_context, indata=b"FOO\n",
2166-
chatty=True, connectionchatty=False, sni_name=None):
2166+
chatty=True, connectionchatty=False, sni_name=None,
2167+
session=None):
21672168
"""
21682169
Launch a server, connect a client to it and try various reads
21692170
and writes.
@@ -2174,7 +2175,7 @@ def server_params_test(client_context, server_context, indata=b"FOO\n",
21742175
connectionchatty=False)
21752176
with server:
21762177
with client_context.wrap_socket(socket.socket(),
2177-
server_hostname=sni_name) as s:
2178+
server_hostname=sni_name, session=session) as s:
21782179
s.connect((HOST, server.port))
21792180
for arg in [indata, bytearray(indata), memoryview(indata)]:
21802181
if connectionchatty:
@@ -2202,6 +2203,8 @@ def server_params_test(client_context, server_context, indata=b"FOO\n",
22022203
'client_alpn_protocol': s.selected_alpn_protocol(),
22032204
'client_npn_protocol': s.selected_npn_protocol(),
22042205
'version': s.version(),
2206+
'session_reused': s.session_reused,
2207+
'session': s.session,
22052208
})
22062209
s.close()
22072210
stats['server_alpn_protocols'] = server.selected_alpn_protocols
@@ -3412,6 +3415,111 @@ def test_sendfile(self):
34123415
s.sendfile(file)
34133416
self.assertEqual(s.recv(1024), TEST_DATA)
34143417

3418+
def test_session(self):
3419+
server_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
3420+
server_context.load_cert_chain(SIGNED_CERTFILE)
3421+
client_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
3422+
client_context.verify_mode = ssl.CERT_REQUIRED
3423+
client_context.load_verify_locations(SIGNING_CA)
3424+
3425+
# first conncetion without session
3426+
stats = server_params_test(client_context, server_context)
3427+
session = stats['session']
3428+
self.assertTrue(session.id)
3429+
self.assertGreater(session.time, 0)
3430+
self.assertGreater(session.timeout, 0)
3431+
self.assertTrue(session.has_ticket)
3432+
if ssl.OPENSSL_VERSION_INFO > (1, 0, 1):
3433+
self.assertGreater(session.ticket_lifetime_hint, 0)
3434+
self.assertFalse(stats['session_reused'])
3435+
sess_stat = server_context.session_stats()
3436+
self.assertEqual(sess_stat['accept'], 1)
3437+
self.assertEqual(sess_stat['hits'], 0)
3438+
3439+
# reuse session
3440+
stats = server_params_test(client_context, server_context, session=session)
3441+
sess_stat = server_context.session_stats()
3442+
self.assertEqual(sess_stat['accept'], 2)
3443+
self.assertEqual(sess_stat['hits'], 1)
3444+
self.assertTrue(stats['session_reused'])
3445+
session2 = stats['session']
3446+
self.assertEqual(session2.id, session.id)
3447+
self.assertEqual(session2, session)
3448+
self.assertIsNot(session2, session)
3449+
self.assertGreaterEqual(session2.time, session.time)
3450+
self.assertGreaterEqual(session2.timeout, session.timeout)
3451+
3452+
# another one without session
3453+
stats = server_params_test(client_context, server_context)
3454+
self.assertFalse(stats['session_reused'])
3455+
session3 = stats['session']
3456+
self.assertNotEqual(session3.id, session.id)
3457+
self.assertNotEqual(session3, session)
3458+
sess_stat = server_context.session_stats()
3459+
self.assertEqual(sess_stat['accept'], 3)
3460+
self.assertEqual(sess_stat['hits'], 1)
3461+
3462+
# reuse session again
3463+
stats = server_params_test(client_context, server_context, session=session)
3464+
self.assertTrue(stats['session_reused'])
3465+
session4 = stats['session']
3466+
self.assertEqual(session4.id, session.id)
3467+
self.assertEqual(session4, session)
3468+
self.assertGreaterEqual(session4.time, session.time)
3469+
self.assertGreaterEqual(session4.timeout, session.timeout)
3470+
sess_stat = server_context.session_stats()
3471+
self.assertEqual(sess_stat['accept'], 4)
3472+
self.assertEqual(sess_stat['hits'], 2)
3473+
3474+
def test_session_handling(self):
3475+
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
3476+
context.verify_mode = ssl.CERT_REQUIRED
3477+
context.load_verify_locations(CERTFILE)
3478+
context.load_cert_chain(CERTFILE)
3479+
3480+
context2 = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
3481+
context2.verify_mode = ssl.CERT_REQUIRED
3482+
context2.load_verify_locations(CERTFILE)
3483+
context2.load_cert_chain(CERTFILE)
3484+
3485+
server = ThreadedEchoServer(context=context, chatty=False)
3486+
with server:
3487+
with context.wrap_socket(socket.socket()) as s:
3488+
# session is None before handshake
3489+
self.assertEqual(s.session, None)
3490+
self.assertEqual(s.session_reused, None)
3491+
s.connect((HOST, server.port))
3492+
session = s.session
3493+
self.assertTrue(session)
3494+
with self.assertRaises(TypeError) as e:
3495+
s.session = object
3496+
self.assertEqual(str(e.exception), 'Value is not a SSLSession.')
3497+
3498+
with context.wrap_socket(socket.socket()) as s:
3499+
s.connect((HOST, server.port))
3500+
# cannot set session after handshake
3501+
with self.assertRaises(ValueError) as e:
3502+
s.session = session
3503+
self.assertEqual(str(e.exception),
3504+
'Cannot set session after handshake.')
3505+
3506+
with context.wrap_socket(socket.socket()) as s:
3507+
# can set session before handshake and before the
3508+
# connection was established
3509+
s.session = session
3510+
s.connect((HOST, server.port))
3511+
self.assertEqual(s.session.id, session.id)
3512+
self.assertEqual(s.session, session)
3513+
self.assertEqual(s.session_reused, True)
3514+
3515+
with context2.wrap_socket(socket.socket()) as s:
3516+
# cannot re-use session with a different SSLContext
3517+
with self.assertRaises(ValueError) as e:
3518+
s.session = session
3519+
s.connect((HOST, server.port))
3520+
self.assertEqual(str(e.exception),
3521+
'Session refers to a different SSLContext.')
3522+
34153523

34163524
def test_main(verbose=False):
34173525
if support.verbose:

Misc/NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ Core and Builtins
138138
Library
139139
-------
140140

141+
- Issue #19500: Add client-side SSL session resumption to the ssl module.
142+
141143
- Issue #28022: Deprecate ssl-related arguments in favor of SSLContext. The
142144
deprecation include manual creation of SSLSocket and certfile/keyfile
143145
(or similar) in ftplib, httplib, imaplib, smtplib, poplib and urllib.

0 commit comments

Comments
 (0)