]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-137197: Add `SSLContext.set_ciphersuites` to set TLSv1.3 ciphers (#137198)
authorRon Frederick <ronf@timeheart.net>
Sat, 30 Aug 2025 09:32:11 +0000 (02:32 -0700)
committerGitHub <noreply@github.com>
Sat, 30 Aug 2025 09:32:11 +0000 (09:32 +0000)
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Doc/library/ssl.rst
Doc/whatsnew/3.15.rst
Lib/test/test_ssl.py
Misc/NEWS.d/next/Library/2025-07-29-05-12-50.gh-issue-137197.bMK3sO.rst [new file with mode: 0644]
Modules/_ssl.c
Modules/clinic/_ssl.c.h

index 18985d423498855ea7ca088dfe1907f937f1f0a7..42fcd38cc7d3dffcd0ad878518893dbfd94415ff 100644 (file)
@@ -1684,19 +1684,33 @@ to speed up repeated connections from the same clients.
 
 .. method:: SSLContext.set_ciphers(ciphers)
 
-   Set the available ciphers for sockets created with this context.
-   It should be a string in the `OpenSSL cipher list format
+   Set the allowed ciphers for sockets created with this context when
+   connecting using TLS 1.2 and earlier.  The *ciphers* argument should
+   be a string in the `OpenSSL cipher list format
    <https://docs.openssl.org/master/man1/ciphers/>`_.
+   To set allowed TLS 1.3 ciphers, use :meth:`SSLContext.set_ciphersuites`.
+
    If no cipher can be selected (because compile-time options or other
    configuration forbids use of all the specified ciphers), an
    :class:`SSLError` will be raised.
 
    .. note::
-      when connected, the :meth:`SSLSocket.cipher` method of SSL sockets will
-      give the currently selected cipher.
+      When connected, the :meth:`SSLSocket.cipher` method of SSL sockets will
+      return details about the negotiated cipher.
+
+.. method:: SSLContext.set_ciphersuites(ciphersuites)
+
+   Set the allowed ciphers for sockets created with this context when
+   connecting using TLS 1.3.  The *ciphersuites* argument should be a
+   colon-separate string of TLS 1.3 cipher names.  If no cipher can be
+   selected (because compile-time options or other configuration forbids
+   use of all the specified ciphers), an :class:`SSLError` will be raised.
+
+   .. note::
+      When connected, the :meth:`SSLSocket.cipher` method of SSL sockets will
+      return details about the negotiated cipher.
 
-      TLS 1.3 cipher suites cannot be disabled with
-      :meth:`~SSLContext.set_ciphers`.
+   .. versionadded:: next
 
 .. method:: SSLContext.set_groups(groups)
 
@@ -2845,10 +2859,15 @@ TLS 1.3
 The TLS 1.3 protocol behaves slightly differently than previous version
 of TLS/SSL. Some new TLS 1.3 features are not yet available.
 
-- TLS 1.3 uses a disjunct set of cipher suites. All AES-GCM and
-  ChaCha20 cipher suites are enabled by default.  The method
-  :meth:`SSLContext.set_ciphers` cannot enable or disable any TLS 1.3
-  ciphers yet, but :meth:`SSLContext.get_ciphers` returns them.
+- TLS 1.3 uses a disjunct set of cipher suites.  All AES-GCM and ChaCha20
+  cipher suites are enabled by default.  To restrict which TLS 1.3 ciphers
+  are allowed, the :meth:`SSLContext.set_ciphersuites` method should be
+  called instead of :meth:`SSLContext.set_ciphers`, which only affects
+  ciphers in older TLS versions.  The :meth:`SSLContext.get_ciphers` method
+  returns information about ciphers for both TLS 1.3 and earlier versions
+  and the method :meth:`SSLSocket.cipher` returns information about the
+  negotiated cipher for both TLS 1.3 and earlier versions once a connection
+  is established.
 - Session tickets are no longer sent as part of the initial handshake and
   are handled differently.  :attr:`SSLSocket.session` and :class:`SSLSession`
   are not compatible with TLS 1.3.
index c38c83d9f45d4e2611cdcf9c4c4376881333fb72..24c19200e035fcdfaf6d094631fdae0cd3044ca2 100644 (file)
@@ -426,6 +426,13 @@ ssl
 
    (Contributed by Ron Frederick in :gh:`136306`)
 
+* Added a new method :meth:`ssl.SSLContext.set_ciphersuites` for setting TLS 1.3
+  ciphers. For TLS 1.2 or earlier, :meth:`ssl.SSLContext.set_ciphers` should
+  continue to be used. Both calls can be made on the same context and the
+  selected cipher suite will depend on the TLS version negotiated when a
+  connection is made.
+  (Contributed by Ron Frederick in :gh:`137197`.)
+
 
 tarfile
 -------
index 7d1eb564930367b93594ce882aab007deb3e508f..b05c7bb059e76d582687747de722898908a1e924 100644 (file)
@@ -263,7 +263,9 @@ ignore_deprecation = warnings_helper.ignore_warnings(
 
 def test_wrap_socket(sock, *,
                      cert_reqs=ssl.CERT_NONE, ca_certs=None,
-                     ciphers=None, certfile=None, keyfile=None,
+                     ciphers=None, ciphersuites=None,
+                     min_version=None, max_version=None,
+                     certfile=None, keyfile=None,
                      **kwargs):
     if not kwargs.get("server_side"):
         kwargs["server_hostname"] = SIGNED_CERTFILE_HOSTNAME
@@ -280,6 +282,12 @@ def test_wrap_socket(sock, *,
         context.load_cert_chain(certfile, keyfile)
     if ciphers is not None:
         context.set_ciphers(ciphers)
+    if ciphersuites is not None:
+        context.set_ciphersuites(ciphersuites)
+    if min_version is not None:
+        context.minimum_version = min_version
+    if max_version is not None:
+        context.maximum_version = max_version
     return context.wrap_socket(sock, **kwargs)
 
 
@@ -2238,6 +2246,68 @@ class SimpleBackgroundTests(unittest.TestCase):
             self.assertRaises(ssl.SSLEOFError, sslobj.read)
 
 
+@unittest.skipUnless(has_tls_version('TLSv1_3'), "TLS 1.3 is not available")
+class SimpleBackgroundTestsTLS_1_3(unittest.TestCase):
+    """Tests that connect to a simple server running in the background."""
+
+    def setUp(self):
+        server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ciphers = [cipher['name'] for cipher in server_ctx.get_ciphers()
+                   if cipher['protocol'] == 'TLSv1.3']
+
+        if not ciphers:
+            self.skipTest("No cipher supports TLSv1.3")
+
+        self.matching_cipher = ciphers[0]
+        # Some tests need at least two ciphers, and are responsible
+        # to skip themselves if matching_cipher == mismatched_cipher.
+        self.mismatched_cipher = ciphers[-1]
+
+        server_ctx.set_ciphersuites(self.matching_cipher)
+        server_ctx.load_cert_chain(SIGNED_CERTFILE)
+        server = ThreadedEchoServer(context=server_ctx)
+        self.enterContext(server)
+        self.server_addr = (HOST, server.port)
+
+    def test_ciphersuites(self):
+        # Test unrecognized TLS 1.3 cipher suite name
+        with (
+            socket.socket(socket.AF_INET) as sock,
+            self.assertRaisesRegex(ssl.SSLError,
+                                   "No cipher suite can be selected")
+        ):
+            test_wrap_socket(sock, cert_reqs=ssl.CERT_NONE,
+                             ciphersuites="XXX",
+                             min_version=ssl.TLSVersion.TLSv1_3)
+
+        # Test successful TLS 1.3 handshake
+        with test_wrap_socket(socket.socket(socket.AF_INET),
+                              cert_reqs=ssl.CERT_NONE,
+                              ciphersuites=self.matching_cipher,
+                              min_version=ssl.TLSVersion.TLSv1_3) as s:
+            s.connect(self.server_addr)
+            self.assertEqual(s.cipher()[0], self.matching_cipher)
+
+    def test_ciphersuite_downgrade(self):
+        with test_wrap_socket(socket.socket(socket.AF_INET),
+                              cert_reqs=ssl.CERT_NONE,
+                              ciphersuites=self.matching_cipher,
+                              min_version=ssl.TLSVersion.TLSv1_2,
+                              max_version=ssl.TLSVersion.TLSv1_2) as s:
+            s.connect(self.server_addr)
+            self.assertEqual(s.cipher()[1], 'TLSv1.2')
+
+    def test_ciphersuite_mismatch(self):
+        if self.matching_cipher == self.mismatched_cipher:
+            self.skipTest("Multiple TLS 1.3 ciphers are not available")
+
+        with test_wrap_socket(socket.socket(socket.AF_INET),
+                              cert_reqs=ssl.CERT_NONE,
+                              ciphersuites=self.mismatched_cipher,
+                              min_version=ssl.TLSVersion.TLSv1_3) as s:
+            self.assertRaises(ssl.SSLError, s.connect, self.server_addr)
+
+
 @support.requires_resource('network')
 class NetworkedTests(unittest.TestCase):
 
diff --git a/Misc/NEWS.d/next/Library/2025-07-29-05-12-50.gh-issue-137197.bMK3sO.rst b/Misc/NEWS.d/next/Library/2025-07-29-05-12-50.gh-issue-137197.bMK3sO.rst
new file mode 100644 (file)
index 0000000..7027504
--- /dev/null
@@ -0,0 +1,2 @@
+:class:`~ssl.SSLContext` objects can now set TLS 1.3 cipher suites
+via :meth:`~ssl.SSLContext.set_ciphersuites`.
index f215bb113087570750aa081782436b2abe8555cb..2388bbb3bae631a5ed08951cb881a3d04e7ebbbe 100644 (file)
@@ -3614,6 +3614,25 @@ _ssl__SSLContext_set_ciphers_impl(PySSLContext *self, const char *cipherlist)
     Py_RETURN_NONE;
 }
 
+/*[clinic input]
+@critical_section
+_ssl._SSLContext.set_ciphersuites
+    ciphersuites: str
+    /
+[clinic start generated code]*/
+
+static PyObject *
+_ssl__SSLContext_set_ciphersuites_impl(PySSLContext *self,
+                                       const char *ciphersuites)
+/*[clinic end generated code: output=9915bec58e54d76d input=2afcc3693392be41]*/
+{
+    if (!SSL_CTX_set_ciphersuites(self->ctx, ciphersuites)) {
+        _setSSLError(get_state_ctx(self), "No cipher suite can be selected.", 0, __FILE__, __LINE__);
+        return NULL;
+    }
+    Py_RETURN_NONE;
+}
+
 /*[clinic input]
 @critical_section
 _ssl._SSLContext.get_ciphers
@@ -5595,6 +5614,7 @@ static struct PyMethodDef context_methods[] = {
     _SSL__SSLCONTEXT__WRAP_SOCKET_METHODDEF
     _SSL__SSLCONTEXT__WRAP_BIO_METHODDEF
     _SSL__SSLCONTEXT_SET_CIPHERS_METHODDEF
+    _SSL__SSLCONTEXT_SET_CIPHERSUITES_METHODDEF
     _SSL__SSLCONTEXT_SET_GROUPS_METHODDEF
     _SSL__SSLCONTEXT__SET_ALPN_PROTOCOLS_METHODDEF
     _SSL__SSLCONTEXT_LOAD_CERT_CHAIN_METHODDEF
index 5b80fab0abb45e40996ad2513724c0f819ecc562..e8b51c1f1e326d9cf72e2e320a3ef15804174d46 100644 (file)
@@ -969,6 +969,45 @@ exit:
     return return_value;
 }
 
+PyDoc_STRVAR(_ssl__SSLContext_set_ciphersuites__doc__,
+"set_ciphersuites($self, ciphersuites, /)\n"
+"--\n"
+"\n");
+
+#define _SSL__SSLCONTEXT_SET_CIPHERSUITES_METHODDEF    \
+    {"set_ciphersuites", (PyCFunction)_ssl__SSLContext_set_ciphersuites, METH_O, _ssl__SSLContext_set_ciphersuites__doc__},
+
+static PyObject *
+_ssl__SSLContext_set_ciphersuites_impl(PySSLContext *self,
+                                       const char *ciphersuites);
+
+static PyObject *
+_ssl__SSLContext_set_ciphersuites(PyObject *self, PyObject *arg)
+{
+    PyObject *return_value = NULL;
+    const char *ciphersuites;
+
+    if (!PyUnicode_Check(arg)) {
+        _PyArg_BadArgument("set_ciphersuites", "argument", "str", arg);
+        goto exit;
+    }
+    Py_ssize_t ciphersuites_length;
+    ciphersuites = PyUnicode_AsUTF8AndSize(arg, &ciphersuites_length);
+    if (ciphersuites == NULL) {
+        goto exit;
+    }
+    if (strlen(ciphersuites) != (size_t)ciphersuites_length) {
+        PyErr_SetString(PyExc_ValueError, "embedded null character");
+        goto exit;
+    }
+    Py_BEGIN_CRITICAL_SECTION(self);
+    return_value = _ssl__SSLContext_set_ciphersuites_impl((PySSLContext *)self, ciphersuites);
+    Py_END_CRITICAL_SECTION();
+
+exit:
+    return return_value;
+}
+
 PyDoc_STRVAR(_ssl__SSLContext_get_ciphers__doc__,
 "get_ciphers($self, /)\n"
 "--\n"
@@ -3142,4 +3181,4 @@ exit:
 #ifndef _SSL_ENUM_CRLS_METHODDEF
     #define _SSL_ENUM_CRLS_METHODDEF
 #endif /* !defined(_SSL_ENUM_CRLS_METHODDEF) */
-/*[clinic end generated code: output=c409bdf3c123b28b input=a9049054013a1b77]*/
+/*[clinic end generated code: output=4e35d2ea2fc46023 input=a9049054013a1b77]*/