Skip to content

gh-136306: Add support for SSL groups #136307

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions Doc/library/ssl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,13 @@ SSL sockets also have the following additional methods and attributes:

.. versionadded:: 3.5

.. method:: SSLSocket.group()

Return the group used for doing key agreement on this connection. If no
connection has been established, returns ``None``.

.. versionadded:: next

.. method:: SSLSocket.compression()

Return the compression algorithm being used as a string, or ``None``
Expand Down Expand Up @@ -1641,6 +1648,25 @@ to speed up repeated connections from the same clients.

.. versionadded:: 3.6

.. method:: SSLContext.get_groups(*, include_aliases=False)

Get a list of groups implemented for key agreement, taking into
account the current TLS :attr:`~SSLContext.minimum_version` and
:attr:`~SSLContext.maximum_version` values. For example::

>>> ctx = ssl.create_default_context()
>>> ctx.minimum_version = ssl.TLSVersion.TLSv1_3
>>> ctx.maximum_version = ssl.TLSVersion.TLSv1_3
>>> ctx.get_groups() # doctest: +SKIP
['secp256r1', 'secp384r1', 'secp521r1', 'x25519', 'x448', ...]

By default, this method returns only the preferred IANA names for the
available groups. However, if the ``include_aliases`` parameter is set to
:const:`True` this method will also return any associated aliases such as
the ECDH curve names supported in older versions of OpenSSL.

.. versionadded:: next

.. method:: SSLContext.set_default_verify_paths()

Load a set of default "certification authority" (CA) certificates from
Expand All @@ -1666,6 +1692,19 @@ to speed up repeated connections from the same clients.
TLS 1.3 cipher suites cannot be disabled with
:meth:`~SSLContext.set_ciphers`.

.. method:: SSLContext.set_groups(groups)

Set the groups allowed for key agreement for sockets created with this
context. It should be a string in the `OpenSSL group list format
<https://docs.openssl.org/master/man3/SSL_CTX_set1_groups_list/>`_.

.. note::

When connected, the :meth:`SSLSocket.group` method of SSL sockets will
return the group used for key agreement on that connection.

.. versionadded:: next

.. method:: SSLContext.set_alpn_protocols(protocols)

Specify which protocols the socket should advertise during the SSL/TLS
Expand Down
18 changes: 18 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ ssl
supports "External PSKs" in TLSv1.3, as described in RFC 9258.
(Contributed by Will Childs-Klein in :gh:`133624`.)

* Added new methods for managing groups used for SSL key agreement

* :meth:`ssl.SSLContext.set_groups` sets the groups allowed for doing
key agreement, extending the previous
:meth:`ssl.SSLContext.set_ecdh_curve` method.
This new API provides the ability to list multiple groups and
supports fixed-field and post-quantum groups in addition to ECDH
curves. This method can also be used to control what key shares
are sent in the TLS handshake.
* :meth:`ssl.SSLSocket.group` returns the group selected for doing key
agreement on the current connection after the TLS handshake completes.
This call requires OpenSSL 3.2 or later.
* :meth:`ssl.SSLContext.get_groups` returns a list of all available key
agreement groups compatible with the minimum and maximum TLS versions
currently set in the context. This call requires OpenSSL 3.5 or later.

(Contributed by Ron Frederick in :gh:`136306`)


tarfile
-------
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(imag)
STRUCT_FOR_ID(importlib)
STRUCT_FOR_ID(in_fd)
STRUCT_FOR_ID(include_aliases)
STRUCT_FOR_ID(incoming)
STRUCT_FOR_ID(index)
STRUCT_FOR_ID(indexgroup)
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions Lib/ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,10 @@ def cipher(self):
ssl_version, secret_bits)``."""
return self._sslobj.cipher()

def group(self):
"""Return the currently selected key agreement group name."""
return self._sslobj.group()

def shared_ciphers(self):
"""Return a list of ciphers shared by the client during the handshake or
None if this is not a valid server connection.
Expand Down Expand Up @@ -1206,6 +1210,14 @@ def cipher(self):
else:
return self._sslobj.cipher()

@_sslcopydoc
def group(self):
self._checkClosed()
if self._sslobj is None:
return None
else:
return self._sslobj.group()

@_sslcopydoc
def shared_ciphers(self):
self._checkClosed()
Expand Down
51 changes: 51 additions & 0 deletions Lib/test/test_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
PROTOCOLS = sorted(ssl._PROTOCOL_NAMES)
HOST = socket_helper.HOST
IS_OPENSSL_3_0_0 = ssl.OPENSSL_VERSION_INFO >= (3, 0, 0)
CAN_GET_SELECTED_OPENSSL_GROUP = ssl.OPENSSL_VERSION_INFO >= (3, 2)
CAN_GET_AVAILABLE_OPENSSL_GROUPS = ssl.OPENSSL_VERSION_INFO >= (3, 5)
PY_SSL_DEFAULT_CIPHERS = sysconfig.get_config_var('PY_SSL_DEFAULT_CIPHERS')

PROTOCOL_TO_TLS_VERSION = {}
Expand Down Expand Up @@ -960,6 +962,19 @@ def test_get_ciphers(self):
len(intersection), 2, f"\ngot: {sorted(names)}\nexpected: {sorted(expected)}"
)

def test_set_groups(self):
ctx = ssl.create_default_context()
self.assertIsNone(ctx.set_groups('P-256:X25519'))
self.assertRaises(ssl.SSLError, ctx.set_groups, 'P-256:xxx')

@unittest.skipUnless(CAN_GET_AVAILABLE_OPENSSL_GROUPS,
"OpenSSL version doesn't support getting groups")
def test_get_groups(self):
ctx = ssl.create_default_context()
# By default, only return official IANA names.
self.assertNotIn('P-256', ctx.get_groups())
self.assertIn('P-256', ctx.get_groups(include_aliases=True))

def test_options(self):
# Test default SSLContext options
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
Expand Down Expand Up @@ -2701,6 +2716,8 @@ def server_params_test(client_context, server_context, indata=b"FOO\n",
'session_reused': s.session_reused,
'session': s.session,
})
if CAN_GET_SELECTED_OPENSSL_GROUP:
stats.update({'group': s.group()})
s.close()
stats['server_alpn_protocols'] = server.selected_alpn_protocols
stats['server_shared_ciphers'] = server.shared_ciphers
Expand Down Expand Up @@ -3851,6 +3868,8 @@ def test_no_shared_ciphers(self):
with self.assertRaises(OSError):
s.connect((HOST, server.port))
self.assertIn("NO_SHARED_CIPHER", server.conn_errors[0])
self.assertIsNone(s.cipher())
self.assertIsNone(s.group())

def test_version_basic(self):
"""
Expand Down Expand Up @@ -4126,6 +4145,38 @@ def test_ecdh_curve(self):
chatty=True, connectionchatty=True,
sni_name=hostname)

def test_groups(self):
# server secp384r1, client auto
client_context, server_context, hostname = testing_context()

server_context.set_groups("secp384r1")
server_context.minimum_version = ssl.TLSVersion.TLSv1_3
stats = server_params_test(client_context, server_context,
chatty=True, connectionchatty=True,
sni_name=hostname)
if CAN_GET_SELECTED_OPENSSL_GROUP:
self.assertEqual(stats['group'], "secp384r1")

# server auto, client secp384r1
client_context, server_context, hostname = testing_context()
client_context.set_groups("secp384r1")
server_context.minimum_version = ssl.TLSVersion.TLSv1_3
stats = server_params_test(client_context, server_context,
chatty=True, connectionchatty=True,
sni_name=hostname)
if CAN_GET_SELECTED_OPENSSL_GROUP:
self.assertEqual(stats['group'], "secp384r1")

# server / client curve mismatch
client_context, server_context, hostname = testing_context()
client_context.set_groups("prime256v1")
server_context.set_groups("secp384r1")
server_context.minimum_version = ssl.TLSVersion.TLSv1_3
with self.assertRaises(ssl.SSLError):
server_params_test(client_context, server_context,
chatty=True, connectionchatty=True,
sni_name=hostname)

def test_selected_alpn_protocol(self):
# selected_alpn_protocol() is None unless ALPN is used.
client_context, server_context, hostname = testing_context()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:mod:`ssl` can now get and set groups used for key agreement.
113 changes: 113 additions & 0 deletions Modules/_ssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,33 @@ _ssl__SSLSocket_cipher_impl(PySSLSocket *self)
return cipher_to_tuple(current);
}

/*[clinic input]
@critical_section
_ssl._SSLSocket.group
[clinic start generated code]*/

static PyObject *
_ssl__SSLSocket_group_impl(PySSLSocket *self)
/*[clinic end generated code: output=9c168ee877017b95 input=5f187d8bf0d433b7]*/
{
#if OPENSSL_VERSION_NUMBER >= 0x30200000L
const char *group_name;

if (self->ssl == NULL) {
Py_RETURN_NONE;
}
group_name = SSL_get0_group_name(self->ssl);
if (group_name == NULL) {
Py_RETURN_NONE;
}
Comment on lines +2161 to +2163
Copy link
Member

Choose a reason for hiding this comment

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

According to the docs, group_name may return NULL in case of an error. Maybe check that there isn't an error?

Copy link
Member

Choose a reason for hiding this comment

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

Note 2: the docs only mentions the case when the TLS session wasn't established yet, but to be on the safe side, I'd prefer checking if there's an error on OpenSSL's side.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are you suggesting adding a unit test here which verifies that OpenSSL returning NULL will hit this code and return None, or are you thinking this should return an error instead?

Right now, this API matches the behavior of SSLSocket.cipher(), returning None when a TLS session is not established (either because of an error or because the application queries this information too early, before the handshake is performed). If there is an error during the handshake, I would expect that to have already raised an error to the calling code. This check is just to handle if the calling code decides to try and query the group despite the incomplete handshake.

Copy link
Member

@picnixz picnixz Jul 12, 2025

Choose a reason for hiding this comment

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

Ah no, I'm just telling to check that we don't have an SSL error set before returning None. If there is an SSL error, we can raise an exception. Otherwise, you can return None as you did.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking at the documentation, I can see where you'd think SSL_get0_group_name() might set an SSL error before returning NULL. Looking at the implementation, though, the functions called by SSL_get0_group_name() return NULL without actually ever setting an error themselves. They just check if other state is NULL or not, and return the name if one is available. I think the error referred to in the docs would have been returned in a prior call, such as when initiating the handshake.

I could put in an explicit call to ERR_get_error here, but I can't find any other code in this module which looks at that to decide whether to return an error. There's always a check for a special error return value in the function before looking at any SSL error information.

In this case, since NULL is returned without setting an SSL error, I would recommend we treat all NULL values as indicating the handshake didn't complete or wasn't attempted yet, and always return None for both cipher and group (and eventually sigalgs when that support is added).

Copy link
Member

Choose a reason for hiding this comment

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

In this case, since NULL is returned without setting an SSL error

Ah, so it would be hard to detect it. Ok, we can return None, and anyway, if there's an issue, subsequent calls to the interface will fail I think.

I would recommend we treat all NULL values as indicating the handshake didn't complete or wasn't attempted yet,

That could be more useful for the user. Can you make some test for that (namely, you set a group, and try to get its name even though the session doesn't exist and see that it returns None). We should also document that it returns None if no session exists yet.

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 doc about returning None is already in place:

.. method:: SSLSocket.group()

   Return the group used for doing key agreement on this connection. If no
   connection has been established, returns ``None``.

   .. versionadded:: next

This is very similar to the docs for SSLSocket.cipher().

I should be able to come up with a unit test which creates an SSLSocket without connecting it and then tries to query the group - working on that now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Trying to get an SSLSocket created without an underlying connection is actually somewhat messy. I think a more realistic case would actually be to try and query the cipher & group after a handshake failure, like no matching ciphers between the client and server. There's already a test for that, and it's straightforward to add in checks that both cipher() and group() return None in that case. Checking that in momentarily.

return PyUnicode_DecodeFSDefault(group_name);
#else
PyErr_SetString(PyExc_NotImplementedError,
"Getting selected group requires OpenSSL 3.2 or later.");
return NULL;
#endif
}

/*[clinic input]
@critical_section
_ssl._SSLSocket.version
Expand Down Expand Up @@ -3023,6 +3050,7 @@ static PyMethodDef PySSLMethods[] = {
_SSL__SSLSOCKET_GETPEERCERT_METHODDEF
_SSL__SSLSOCKET_GET_CHANNEL_BINDING_METHODDEF
_SSL__SSLSOCKET_CIPHER_METHODDEF
_SSL__SSLSOCKET_GROUP_METHODDEF
_SSL__SSLSOCKET_SHARED_CIPHERS_METHODDEF
_SSL__SSLSOCKET_VERSION_METHODDEF
_SSL__SSLSOCKET_SELECTED_ALPN_PROTOCOL_METHODDEF
Expand Down Expand Up @@ -3402,6 +3430,89 @@ _ssl__SSLContext_get_ciphers_impl(PySSLContext *self)

}

/*[clinic input]
@critical_section
_ssl._SSLContext.set_groups
grouplist: str
/
[clinic start generated code]*/

static PyObject *
_ssl__SSLContext_set_groups_impl(PySSLContext *self, const char *grouplist)
/*[clinic end generated code: output=0b5d05dfd371ffd0 input=2cc64cef21930741]*/
{
if (!SSL_CTX_set1_groups_list(self->ctx, grouplist)) {
_setSSLError(get_state_ctx(self), "unrecognized group", 0, __FILE__, __LINE__);
return NULL;
}
Py_RETURN_NONE;
}

/*[clinic input]
@critical_section
_ssl._SSLContext.get_groups
*
include_aliases: bool = False
[clinic start generated code]*/

static PyObject *
_ssl__SSLContext_get_groups_impl(PySSLContext *self, int include_aliases)
/*[clinic end generated code: output=6d6209dd1051529b input=3e8ee5deb277dcc5]*/
{
#if OPENSSL_VERSION_NUMBER >= 0x30500000L
STACK_OF(OPENSSL_CSTRING) *groups = NULL;
const char *group;
int i, num;
PyObject *item, *result = NULL;

// This "groups" object is dynamically allocated, but the strings inside
// it are internal constants which shouldn't ever be modified or freed.
if ((groups = sk_OPENSSL_CSTRING_new_null()) == NULL) {
_setSSLError(get_state_ctx(self), "Can't allocate stack", 0, __FILE__, __LINE__);
goto error;
}

if (!SSL_CTX_get0_implemented_groups(self->ctx, include_aliases, groups)) {
_setSSLError(get_state_ctx(self), "Can't get groups", 0, __FILE__, __LINE__);
goto error;
}

num = sk_OPENSSL_CSTRING_num(groups);
result = PyList_New(num);
if (result == NULL) {
_setSSLError(get_state_ctx(self), "Can't allocate list", 0, __FILE__, __LINE__);
goto error;
}

for (i = 0; i < num; ++i) {
// There's no allocation here, so group won't ever be NULL.
group = sk_OPENSSL_CSTRING_value(groups, i);
assert(group != NULL);

// Group names are plain ASCII, so there's no chance of a decoding
// error here. However, an allocation failure could occur when
// constructing the Unicode version of the names.
item = PyUnicode_DecodeASCII(group, strlen(group), "strict");
if (item == NULL) {
_setSSLError(get_state_ctx(self), "Can't allocate group name", 0, __FILE__, __LINE__);
goto error;
}

PyList_SET_ITEM(result, i, item);
}

sk_OPENSSL_CSTRING_free(groups);
return result;
error:
Py_XDECREF(result);
sk_OPENSSL_CSTRING_free(groups);
return NULL;
#else
PyErr_SetString(PyExc_NotImplementedError,
"Getting implemented groups requires OpenSSL 3.5 or later.");
return NULL;
#endif
}

static int
do_protocol_selection(int alpn, unsigned char **out, unsigned char *outlen,
Expand Down Expand Up @@ -5249,6 +5360,7 @@ static struct PyMethodDef context_methods[] = {
_SSL__SSLCONTEXT__WRAP_SOCKET_METHODDEF
_SSL__SSLCONTEXT__WRAP_BIO_METHODDEF
_SSL__SSLCONTEXT_SET_CIPHERS_METHODDEF
_SSL__SSLCONTEXT_SET_GROUPS_METHODDEF
_SSL__SSLCONTEXT__SET_ALPN_PROTOCOLS_METHODDEF
_SSL__SSLCONTEXT_LOAD_CERT_CHAIN_METHODDEF
_SSL__SSLCONTEXT_LOAD_DH_PARAMS_METHODDEF
Expand All @@ -5259,6 +5371,7 @@ static struct PyMethodDef context_methods[] = {
_SSL__SSLCONTEXT_CERT_STORE_STATS_METHODDEF
_SSL__SSLCONTEXT_GET_CA_CERTS_METHODDEF
_SSL__SSLCONTEXT_GET_CIPHERS_METHODDEF
_SSL__SSLCONTEXT_GET_GROUPS_METHODDEF
_SSL__SSLCONTEXT_SET_PSK_CLIENT_CALLBACK_METHODDEF
_SSL__SSLCONTEXT_SET_PSK_SERVER_CALLBACK_METHODDEF
{NULL, NULL} /* sentinel */
Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy