`SSLSocket.shared_ciphers()` does not document `None` is returned on session reuse
Documentation
SSLSocket.shared_ciphers() does not document None is returned on session reuse
Summary
The fix of #96931 resulted in a change in SSLSocket.shared_ciphers().
If the session is reused, SSLSocket.shared_ciphers() returns None
Proposal: update documentation of SSLSocket.shared_ciphers() to note None is returned on session reuse.
Background & Motivation
As an example, here is a sample server.py and client.py scripts to show the behavior change in Python 3.11.2 and 3.11.3:
# server.py import socket import ssl import platform context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile="ca.pem") context.load_cert_chain(certfile="server.pem") port = 12345 bindsocket = socket.socket() bindsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) bindsocket.bind(("localhost", port)) bindsocket.listen(5) print("Python version: {}".format(platform.python_version())) print("server listening on port {}".format(port)) while True: newsocket, fromaddr = bindsocket.accept() connstream: ssl.SSLContext.sslsocket_class = context.wrap_socket( newsocket, server_side=True, do_handshake_on_connect=True) print("server got connection on address: {}".format(fromaddr)) print("server shared ciphers: {}".format(connstream.shared_ciphers())) print("server session reused? {}".format(connstream.session_reused)) data = connstream.recv(1024) while data: print("server got data {}".format(data)) data = connstream.recv(1024) print("server finished with client") connstream.close()
# client.py import socket import ssl port = 12345 """ Use TLS 1.2 so session ticket is sent. https://docs.python.org/3/library/ssl.html#ssl-session describes: > Session tickets are no longer sent as part of the initial handshake and are handled differently. SSLSocket.session and SSLSession are not compatible with TLS 1.3. """ context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLSv1_2) context.load_verify_locations(cafile="ca.pem") conn: ssl.SSLSocket = context.wrap_socket(socket.socket(socket.AF_INET), server_hostname="localhost") conn.connect(("localhost", port)) conn.write(b"foo") assert not conn.session_reused session = conn.session conn.close() # Connect again and reuse the session. conn = context.wrap_socket(socket.socket(socket.AF_INET), server_hostname="localhost", session=session) conn.connect(("localhost", port)) conn.write(b"foo") assert conn.session_reused conn.close()
Here is the output of server.py on Python 3.11.2:
Python version: 3.11.2
server listening on port 12345
server got connection on address: ('127.0.0.1', 64310)
server shared ciphers: [('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256), ('TLS_CHACHA20_POLY1305_SHA256', 'TLSv1.3', 256), ('TLS_AES_128_GCM_SHA256', 'TLSv1.3', 128), ('ECDHE-ECDSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('ECDHE-RSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('ECDHE-RSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('ECDHE-ECDSA-CHACHA20-POLY1305', 'TLSv1.2', 256), ('ECDHE-RSA-CHACHA20-POLY1305', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES256-SHA384', 'TLSv1.2', 256), ('ECDHE-RSA-AES256-SHA384', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES128-SHA256', 'TLSv1.2', 128), ('ECDHE-RSA-AES128-SHA256', 'TLSv1.2', 128), ('DHE-RSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('DHE-RSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('DHE-RSA-AES256-SHA256', 'TLSv1.2', 256), ('DHE-RSA-AES128-SHA256', 'TLSv1.2', 128)]
server session reused? False
server got data b'foo'
server finished with client
server got connection on address: ('127.0.0.1', 64311)
server shared ciphers: [('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256), ('TLS_CHACHA20_POLY1305_SHA256', 'TLSv1.3', 256), ('TLS_AES_128_GCM_SHA256', 'TLSv1.3', 128), ('ECDHE-ECDSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('ECDHE-RSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('ECDHE-RSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('ECDHE-ECDSA-CHACHA20-POLY1305', 'TLSv1.2', 256), ('ECDHE-RSA-CHACHA20-POLY1305', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES256-SHA384', 'TLSv1.2', 256), ('ECDHE-RSA-AES256-SHA384', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES128-SHA256', 'TLSv1.2', 128), ('ECDHE-RSA-AES128-SHA256', 'TLSv1.2', 128), ('DHE-RSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('DHE-RSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('DHE-RSA-AES256-SHA256', 'TLSv1.2', 256), ('DHE-RSA-AES128-SHA256', 'TLSv1.2', 128)]
server session reused? True
server got data b'foo'
server finished with client
Here is the output of server.py on Python 3.11.3:
Python version: 3.11.3
server listening on port 12345
server got connection on address: ('127.0.0.1', 64316)
server shared ciphers: [('ECDHE-ECDSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('ECDHE-RSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('ECDHE-RSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('ECDHE-ECDSA-CHACHA20-POLY1305', 'TLSv1.2', 256), ('ECDHE-RSA-CHACHA20-POLY1305', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES256-SHA384', 'TLSv1.2', 256), ('ECDHE-RSA-AES256-SHA384', 'TLSv1.2', 256), ('ECDHE-ECDSA-AES128-SHA256', 'TLSv1.2', 128), ('ECDHE-RSA-AES128-SHA256', 'TLSv1.2', 128), ('DHE-RSA-AES256-GCM-SHA384', 'TLSv1.2', 256), ('DHE-RSA-AES128-GCM-SHA256', 'TLSv1.2', 128), ('DHE-RSA-AES256-SHA256', 'TLSv1.2', 256), ('DHE-RSA-AES128-SHA256', 'TLSv1.2', 128)]
server session reused? False
server got data b'foo'
server finished with client
server got connection on address: ('127.0.0.1', 64317)
server shared ciphers: None
server session reused? True
server got data b'foo'
server finished with client
In 3.11.3, after the session is reused, the return of shared_ciphers() is None. The scripts and test certificates are located here.
Alternatives
openssl/openssl#4295 suggest alternative API to use in OpenSSL:
Ah, the shared ciphers would only be computed when negotiation is performed (i.e., not resumption), yes. Hopefully you can update to a supported version of OpenSSL and pick up the needed funcitonality.
An alternative implementation could be to use SSL_CTX_set_client_hello_cb to obtain the list of ciphers sent in the ClientHello. Store the list of ciphers for retrieval in shared_ciphers(). #110902 implements this change but is (at present) left as draft. Storing the ciphers requires additional memory per socket and may not provide much value to users. Instead, #106345 proposes a documentation only change to inform users.
Known Impact
The None return resulted in a bug report in PyKMIP. The call to shared_ciphers was not checking for the None return value: OpenKMIP/PyKMIP#700