Skip to content

Conversation

ronf
Copy link
Contributor

@ronf ronf commented Jul 29, 2025

This commit adds a new method SSLContext.set_ciphersuites which can be used to set TLS 1.3 cipher suites. It also updates the documentation, unit tests, and "what's new" text. A NEWS blurb will be added shortly.


📚 Documentation preview 📚: https://cpython-previews--137198.org.readthedocs.build/

Modules/_ssl.c Outdated
@@ -3595,12 +3595,27 @@ _ssl__SSLContext_set_ciphers_impl(PySSLContext *self, const char *cipherlist)
{
int ret = SSL_CTX_set_cipher_list(self->ctx, cipherlist);
if (ret == 0) {
/* Clearing the error queue is necessary on some OpenSSL versions,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't remove this without understanding which OpenSSL versions it was referring to and whether they are still in use by CPython builds (1.1.x-ish API'd AWS-LC at a minimum, otherwise OpenSSL 3.0+).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clearing of the error queue is still happening here. I just took advantage of an existing helper function setSSLError to take care of this:

static PyObject *
_setSSLError (_sslmodulestate *state, const char *errstr, int errcode, const char *filename, int lineno)
{
    if (errstr == NULL)
        errcode = ERR_peek_last_error();
    else
        errcode = 0;
    fill_and_set_sslerror(state, NULL, state->PySSLErrorObject, errcode, errstr, lineno, errcode);
    ERR_clear_error();
    return NULL;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep it that way and change it in a follow-up PR instead. Actually, the errcode parameter for _setSSLError is redundant as it's always overwritten.

@ronf ronf requested a review from AA-Turner as a code owner August 9, 2025 00:23
ronf and others added 4 commits August 8, 2025 18:00
Clarify when to use the original set_ciphers (TLS 1.2 and earlier)
vs. the new set_ciphersuites (TLS 1.3) methods and that both can
be used at once.
@ronf ronf force-pushed the ssl-ciphersuite-support branch from f2cda88 to d3375f1 Compare August 9, 2025 01:00
@ronf
Copy link
Contributor Author

ronf commented Aug 9, 2025

I think all the remaining review comments should be addressed in 48e5164. Let me know if you'd like any other changes.

@@ -263,7 +263,8 @@ def utc_offset(): #NOTE: ignore issues like #1647654

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for the future, but how about adding max_version as well? and put min_version and max_version on their own lines? even if we don't use it, I think it's fine to add it just in case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current version of test_wrap_socket() only lets you specify min_version for now. It would be easy to add max, but it's called from 35 places. Would you change all of these to set both min & max?

The defaults for min/max version are special symbols MININUM_SUPPORTED and MAXIMUM_SUPPORTED, so there's no need to set the maximum unless you want to forcibly restrict it (in the future) to not using a TLS version greater than 1.3. I'm not sure I see a good reason to do that here, at least not yet. It would really come down to whether TLS 1.4 did something to yet again change the method used to set cipher suites, which I'm hoping won't be the case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it was more to ease checks in internal code paths.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm not sure what you mean.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion was:

def test_wrap_socket(sock, *,
                     cert_reqs=ssl.CERT_NONE, ca_certs=None,
                     ciphers=None, ciphersuites=None,
                     min_version=None, max_version=None,
                     ...):

    ...
    if min_version is not None:
        context.minimum_version = min_version
    if max_version is not None:
        context.maximum_version = max_version
    ...

That way, we can set maximum_version in tests if needed. For now, we don't but in the future we could. For instance, a test where we call set_ciphersuites but with context.maximum_version = ssl.TLSVersion.TLSv1_2. Or is the maximum version actually ignored?

@@ -1863,6 +1868,10 @@ class SimpleBackgroundTests(unittest.TestCase):

def setUp(self):
self.server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)

if has_tls_version('TLSv1_3'):
self.server_context.set_ciphersuites('TLS_AES_256_GCM_SHA384')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could raise so we should be careful (for weird build options, I think it's possible to disable TLS_AES_256_GCM_SHA384). I would suggest only calling set_ciphersuites in tests that need it (that is those decorated by requires_tls_version. Otherwise the server_context would unconditoinally have this ciphersuite even if a test doesn't need it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must admit I didn't love that change, but the problem here is in testing the mismatched ciphers case. I we leave the server accepting all ciphers, then there's no way to trigger a mismatched cipher, and I think the server is set up only once and then re-used for multiple test methods.

This mismatch test could be broken into its own test class so that we only set client and server cipher suites for that one specific test, but it would mean adding more boiler plate to set up the server in two different classes.

I felt pretty comfortable letting it be shared as this change doesn't impact the set of TLS 1.2 cipher suites used by any of these tests (either client or server) and any existing code would not be attempting to set any TLS 1.3 ciphers as that functionality didn't exist. Some of those tests might use TLS 1.3 today based on the defaults set but changing the server side to a specific cipher will still allow TLS 1.3 to be used, since the client-side would not be restricting TLS 1.3 ciphers.

That said, if you'd prefer I replicate the server setup to restrict this, I'm happy to do so.

As for the specific cipher I chose, I tried to pick the most "vanilla" one I could. For a test like this to exist, we're going to have to pick SOMETHING on the client and something else on the server to create the mismatch. I could try to do something like a get_ciphers(), filtering on TLS 1.3, and then pick two different values from that list, but it would complicate the test. For the unit testing, do you expect any of the test environments to disable these cipher suites? I see other test code hardcoding ciphers like these.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and I think the server is set up only once and then re-used for multiple test methods.

The server context is new for every test though? setUp() is called before every test method. If you want to ease your life, just have different test methods, each of them will have their own server_context that is independent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking of setUpClass() instead of setUp(), which only runs once for all tests in a class. However, even with setUp() running before each test, I'd still need to somehow pass into setUp() whether to restrict the ciphers on a test-by-test basis, and I'm not sure how to do that.

Since there's not a lot of setup code, I'm going to see what it looks like if I add a new class, with its own separate setUp() function, used only for this mismatch test. That'll minimize changes made to the existing test code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, I see the issue. It's because we create the server before and changing its context afterwards wouldn't help right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's right.

Copy link
Contributor Author

@ronf ronf Aug 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok - I've checked in a new version of the test cases which uses its own class, reverting the changes to the previously modified class. This version also attempts to dynamically select ciphers from the list of available ciphers on the system. It assumes at least one cipher is available when the TLS 1.3 enabled check passes, but it will skip the mismatched cipher test if there's only one available TLS 1.3 cipher.

ronf added 2 commits August 9, 2025 13:23
This commit reworks the set_ciphersuites() test cases, moving them into
their own class to avoid any changes to existing tests. It also makes
the cipher selection dynamic to avoid potentially trying to use a cipher
not available in some environments.
@ronf
Copy link
Contributor Author

ronf commented Aug 16, 2025

@picnixz, any further changes required? The build failure appears to be something in the test runner, as the only thing changed in the last checkin was capitalization in the docs. Prior to that, all tests succeeded.

@picnixz
Copy link
Member

picnixz commented Aug 16, 2025

I'm not available before next week but I'll have a look.

@picnixz picnixz self-assigned this Aug 16, 2025
@ronf
Copy link
Contributor Author

ronf commented Aug 17, 2025

That'd be great - thanks!

I spent some time today working on adding methods to manage signature algorithms and will create a new issue & PR for those as soon as these changes are merged.

@picnixz picnixz self-requested a review August 18, 2025 10:36
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 TLS1.3 ciphers
are allowed, the method :meth:`SSLContext.set_ciphersuites` should be
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For posterity: I really hate that OpenSSL named it SSL_CTX_set_ciphersuites for TLS 1.3 and later, and SSL_CTX_set_cipher_list for TLS 1.2 and below. I hope that this won't be annoying to users.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised what they did here at first and I'm not a big fan about the naming chosen, but I can see why they didn't want to try and extend the existing command. It allows all kinds of partial matches on the five individual elements that make up a cipher suite in TLS 1.2 and earlier, plus other special keywords like LOW/MEDIUM/HIGH. All of that is gone with TLS 1.3, and the new function is much simpler and only supports an exact match against the very small number of cipher suites defined in TLS 1.3.

I don't really know why they didn't include "_list" in the name of the OpenSSL function, given that this seems to be the convention for other such calls that take a string argument.

The good news here is that things like post-quantum crypto are going to drive migration to TLS 1.3, and at some point setting TLS 1.2 cipher suites will no longer matter. At that point, the previous set_ciphers() can be deprecated and eventually removed, with only set_ciphersuites() remaining.

@@ -263,7 +263,8 @@ def utc_offset(): #NOTE: ignore issues like #1647654

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion was:

def test_wrap_socket(sock, *,
                     cert_reqs=ssl.CERT_NONE, ca_certs=None,
                     ciphers=None, ciphersuites=None,
                     min_version=None, max_version=None,
                     ...):

    ...
    if min_version is not None:
        context.minimum_version = min_version
    if max_version is not None:
        context.maximum_version = max_version
    ...

That way, we can set maximum_version in tests if needed. For now, we don't but in the future we could. For instance, a test where we call set_ciphersuites but with context.maximum_version = ssl.TLSVersion.TLSv1_2. Or is the maximum version actually ignored?

Modules/_ssl.c Outdated
@@ -3595,12 +3595,27 @@ _ssl__SSLContext_set_ciphers_impl(PySSLContext *self, const char *cipherlist)
{
int ret = SSL_CTX_set_cipher_list(self->ctx, cipherlist);
if (ret == 0) {
/* Clearing the error queue is necessary on some OpenSSL versions,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep it that way and change it in a follow-up PR instead. Actually, the errcode parameter for _setSSLError is redundant as it's always overwritten.

@ronf
Copy link
Contributor Author

ronf commented Aug 21, 2025

Thanks for your review! I think I've addressed this round of review comments, including reverting the change to _ssl__SSLContext_set_ciphers_impl for now. Let me know if anything else is needed.

@picnixz picnixz self-requested a review August 22, 2025 08:11
@ronf
Copy link
Contributor Author

ronf commented Aug 28, 2025

Just checking in - are there any further changes you'd like me to make?

Copy link
Member

@picnixz picnixz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few nitpicks.

Copy link
Member

@picnixz picnixz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A final nitpick and we're good to go I think

@ronf
Copy link
Contributor Author

ronf commented Aug 29, 2025

I've gone ahead and created issue #138252 in preparation for the next round of changes, to add support for getting and setting TLS signature algorithms. I have those changes ready, but will hold off until this change is merged before I submit the PR, to avoid any merge conflicts.

Copy link
Member

@picnixz picnixz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to amend it directly and merge this.

@@ -2238,6 +2246,67 @@ def test_transport_eof(self):
self.assertRaises(ssl.SSLEOFError, sslobj.read)


@unittest.skipUnless(has_tls_version('TLSv1_3'), "TLS 1.3 is not available")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to myself: change the requires_tls_version decorator so that it works.

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')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is asserting that the default TLSv1_2 cipher suite is actually being used right? I'm fine with the current test but I'd like to fortify it (which I'll do) in the future where we have a ciphersuite for TLSv1.3 and a ciphersuite for TLSv1.2 and if we use min/max versions, then we're going to be using the TLSv1.2 ciphersuite we specified.

@picnixz picnixz enabled auto-merge (squash) August 30, 2025 09:12
@picnixz picnixz merged commit bacb777 into python:main Aug 30, 2025
45 checks passed
@ronf ronf deleted the ssl-ciphersuite-support branch August 30, 2025 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants