Skip to content

Conversation

johubertj
Copy link
Contributor

@johubertj johubertj commented Jul 28, 2025

Release Summary:

Added pure ML-KEM 1024 (non-hybrid) key exchange support for TLS 1.3.

Resolved issues:

Partially addresses #5152

Description of changes:

Pure ML-KEM removes the ECC component, reducing handshake complexity and ciphertext size, and aligns with CNSA 2.0 requirements for PQ-only TLS

New KEM Group: Defined s2n_pure_mlkem_1024 and ECC placeholder (s2n_ecc_curve_mlkem_placeholder) for shared code paths.

Handshake Updates:

  • ClientHello send: Generate only ML-KEM key pair and public key; skip ECC generation.
  • Server recv: Parse client ML-KEM public key and store for encapsulation.
  • ServerHello send: Encapsulate shared secret with client’s public key; send ciphertext.
  • Client recv: Decapsulate server ciphertext to recover shared secret.

Testing:

Unit Tests: KEM group parsing, feature probing, key exchange & key schedule validation, policy negotiation.
Manual Integration Test: Handshake w/ s2nd and openssl3.5 using pure mlkem 1024
Negative manual integration test: Removed Pure ML-KEM 1024 support and required OpenSSL 3.5 to negotiate Pure ML-KEM 1024. As expected, the handshake failed.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@github-actions github-actions bot added the s2n-core team label Jul 28, 2025
@johubertj johubertj changed the title feat: add mlkem_1024 kem group feat: add pure mlkem_1024 Jul 29, 2025
@johubertj johubertj force-pushed the feat/add_pure_mlkem branch 2 times, most recently from d737fde to bfa016d Compare August 12, 2025 22:55
@johubertj
Copy link
Contributor Author

johubertj commented Aug 14, 2025

Manual Integration test:

End-to-end testing performed with OpenSSL 3.5 and s2nd.

Verified handshake negotiation with pure_mlkem_1024 by temporarily placing it at the top of the default_pq policy preference list.

s2nd

./build/bin/s2nd localhost 4433 -c default_pq
--cert tests/pems/mldsa/ML-DSA-87.crt
--key tests/pems/mldsa/ML-DSA-87-seed.priv
libcrypto: AWS-LC 1.55.0
Listening on localhost:4433
CONNECTED:
Handshake: NEGOTIATED|FULL_HANDSHAKE|MIDDLEBOX_COMPAT
Client hello version: 33
Client protocol version: 34
Server protocol version: 34
Actual protocol version: 34
KEM Group: MLKEM1024 (PQ key exchange enabled)
Cipher negotiated: TLS_AES_128_GCM_SHA256
Server signature negotiated: MLDSA+None
Early Data status: NOT REQUESTED
JA3: 2dadf00b65ca9c6d98745bf1ffe22ada
Wire bytes in: 1882
Wire bytes out: 14105
s2n is ready

OpenSSL 3.5

ubuntu@ip-172-31-11-69:/openssl$ ./build/bin/openssl s_client
-groups MLKEM1024
-connect localhost:4433
Connecting to 127.0.0.1
CONNECTED(00000003)
Can't use SSL_get_servername
depth=0 O=IETF, CN=LAMPS WG
verify error:num=18:self-signed certificate
verify return:1
depth=0 O=IETF, CN=LAMPS WG
verify return:1
--
Certificate chain
0 s:O=IETF, CN=LAMPS WG
i:O=IETF, CN=LAMPS WG
a:PKEY: ML-DSA-87, 20736 (bit); sigalg: ML-DSA-87
v:NotBefore: Feb 3 04:32:10 2020 GMT; NotAfter: Jan 29 04:32:10 2040 GMT
--
Server certificate
-----BEGIN CERTIFICATE-----
MIIdMzCCCwqgAwIBAgIUFZ/+b................
-----END CERTIFICATE-----
subject=O=IETF, CN=LAMPS WG
issuer=O=IETF, CN=LAMPS WG
--
No client certificate CA names sent
Peer signature type: mldsa44
Negotiated TLS1.3 group: MLKEM1024
--
SSL handshake has read 13926 bytes and written 1882 bytes
Verification error: self-signed certificate
--
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Protocol: TLSv1.3
Server public key is 20736 bit
This TLS version forbids renegotiation.
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 18 (self-signed certificate)
--
--
Post-Handshake New Session Ticket arrived:
SSL-Session:
Protocol : TLSv1.3
Cipher : TLS_AES_128_GCM_SHA256
Session-ID: 510253803712484562208BCF5E5D9BAFE30556E03EAD85613EAE619E3C748EC4
Session-ID-ctx:
Resumption PSK: 34F497D4B90026A70EE349D6A65842C1D197912E2C7478CEE775BF60795B6308
PSK identity: None
PSK identity hint: None
SRP username: None
TLS session ticket lifetime hint: 53990 (seconds)
TLS session ticket:
0000 - 01 32 30 31 36 2e 30 37-2e 32 36 2e 31 35 00 00 .2016.07.26.15..
0010 - 00 85 7b fc b4 f0 ca 93-b8 9c 98 16 41 62 e2 84 ..{.........Ab..
0020 - 4f c4 8b 7b 0f f3 95 7a-db fa d8 d8 f1 15 c9 04 O..{...z........
0030 - e4 d3 1e 8f 75 ed a2 a1-0c 05 d5 16 c3 3b 10 31 ....u........;.1
0040 - b5 55 c9 45 8a 93 0d a8-af 3c 9d a2 bb ff 86 a5 .U.E.....<......
0050 - 43 86 69 97 6f 13 4e 10-2b d7 b9 b8 bd 68 d8 03 C.i.o.N.+....h..
0060 - 34 b5 96 3b 7f 90 29 01-5f 03 69 9d 8b 5e c6 23 4..;..)._.i..^.#
0070 - ac 75 ed 01 e0 0e 46 7e-fc b9 17 46 20 41 e3 b0 .u....F~...F A..
0080 - 2f c6 56 26 ac 61 6d 37-da 9f /.V&.am7..
Start Time: 1755198798
Timeout : 7200 (sec)
Verify return code: 18 (self-signed certificate)
Extended master secret: no
Max Early Data: 0
--
read R BLOCK

added mlkem placeholder

key exchange + key schedule unit test

keep hybrid info in kat file

pq handshake

minor bugs and comment

formatting

removal debugging printstatements

update client key share

fixed recv stuff

more refactor

updated server

clang formatting and skip pure mlkem if pq is disabled

added skip

added real curve for real runs
@johubertj johubertj force-pushed the feat/add_pure_mlkem branch from b7135a1 to 8c2550b Compare August 15, 2025 23:28
@johubertj
Copy link
Contributor Author

Manual Integration test:
End-to-end testing performed with OpenSSL 3.5 and s2nd.

Verified handshake failure with pure_mlkem_1024 by temporarily removing it from the default_pq policy preference list while requiring it on the client side.

s2nd

ubuntu@ip-172-31-11-69:~/s2n-tls$ •/build/bin/s2nd localhost 4433 -c default_pq
--cert tests/pems/mldsa/ML-DSA-87.crt
--key
tests/pems/mldsa/ML-DSA-87-seed.priv
libcrypto: AWS-LC 1.55.0
Listening on localhost: 4433
DEBUG [key_share_recv]: Detected pure MLKEM group, calling pure_kem recv
DEBUG: Entering pure MLKEM recv: named_group=514,
expected_iana=514
DEBUG:
DEBUG:
actual_share_size=1568, unprefixed_size=1568, prefixed_size=1570
len_prefixed=0
DEBUG: KEM public key parsed, size=1568
DEBUG: key_share.read_cursor=1568, key_share.write_cursor=1568
DEBUG:
remaining bytes in key_share=0
DEBUG: Finished pure MLKEM recv successfully
DEBUG [key_share_recv]: After pure_kem →> extension_read=1574 extension_write=1574 extension_available=0
DEBUG[key_share_recv]: pure_kem recv success
Failed to negotiate: 'Unsupported EC curve was presented during an ECDHE handshake'. Error encountered i n /home/ubuntu/s2n-tls/tls/extensions/s2n_server_key_share.c:506

OpenSSL 3.5

ubuntu@ip-172-31-11-69:~/openssl$ •/build/bin/openssl s_client
-groups MLKEM1024
-connect localhost: 4433
Connecting
to 127.0.0.1
CONNECTED (00000003)
4097C1D2D5740000: error: 0A000126:SSL routines: :unexpected eof while reading: ssl/record/rec_layer_s3.c: 691
no peer certificate available
No client certificate CA names sent
Negotiated TLS1.3 group: «NULL>
SSL
handshake has read 0 bytes and written 1825 bytes
Verification: 0K
New, (NONE), Cipher is (NONE)
Protocol: TLSv1.3
This TLS version forbids
Compression: NONE
renegotiation.
Expansion: NONE
No ALPN negotiated
Early data
was not sent
Verify return code: 0 (ok)

@@ -64,6 +64,13 @@ const struct s2n_ecc_named_curve *const s2n_ecc_pref_list_default_fips[] = {
&s2n_ecc_curve_secp384r1,
};

const struct s2n_ecc_named_curve *const s2n_ecc_pref_list_pure_mlkem[] = {
#if EVP_APIS_SUPPORTED
&s2n_ecc_curve_x25519,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

List includes at least one real ECC curve (x25519) so the handshake fallback logic never picks the mlkem_placeholder as the default curve.

Comment on lines -116 to +115
POSIX_ENSURE((expected_kem_group == NULL) != (expected_curve == NULL), S2N_ERR_SAFETY);
/* Skip XOR check for pure MLKEM since we have a KEM group and a curve (placeholder), otherwise enforce XOR check
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do you need to skip this check? If your placeholder curve is a problem, why does hybrid mlkem work here?

Comment on lines +806 to +807
.expected_kem_group = null_if_no_pure_mlkem_1024,
.expected_curve = ec_if_no_pure_mlkem,
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't "expected curve" the curve negotiated if PQ / MLKEM isn't supported? Is this saying that the placeholder curve is negotiated in that case? Shouldn't the placeholder curve NEVER be negotiated?

Have you tested falling back to classic curves with pure MLKEM when handshaking with a peer that doesn't support PQ?

Comment on lines 456 to 524
/* Try to parse the share as ECC, then as PQ/hybrid; will ignore
* shares for unrecognized groups. */
if (named_group == s2n_pure_mlkem_1024.iana_id) {
POSIX_GUARD(s2n_client_key_share_recv_pure_kem(conn, &key_share, named_group));
continue;
}

POSIX_GUARD(s2n_client_key_share_recv_ecc(conn, &key_share, named_group));
POSIX_GUARD(s2n_client_key_share_recv_pq_hybrid(conn, &key_share, named_group));
Copy link
Contributor

Choose a reason for hiding this comment

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

This organization doesn't seem right. This function is delegating responsibility for recognizing ecc vs hybrid-- why would it not also delegate responsibility for recognizing pure?

Comment on lines 71 to 72
&s2n_ecc_curve_mlkem_placeholder,
};
Copy link
Contributor

Choose a reason for hiding this comment

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

We should NOT need to include the placeholder on any of these lists. That implies we're allowed to negotiate the placeholder! In fact, there should be a unit test that enforces that no preference list includes the placeholder.

Comment on lines 222 to 224
POSIX_GUARD(s2n_generate_pure_pq_key_share(out, client_params));
} else {
POSIX_GUARD(s2n_generate_pq_hybrid_key_share(out, client_params));
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: "pure_pq" vs "pq_hybrid" seems like confusing naming :) Let's keep related functions named in a way that makes their similarities / differences clear. So probably "pq_pure" and "pq_hybrid".

Comment on lines 143 to 144
static int s2n_generate_pure_pq_key_share(struct s2n_stuffer *out,
struct s2n_kem_group_params *kem_group_params)
Copy link
Contributor

Choose a reason for hiding this comment

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

You're missing the s2n_pq_is_enabled() check that s2n_generate_pq_hybrid_key_share makes.

You're also duplicating all the logic in s2n_generate_pq_hybrid_key_share. Is there a cleaner way to handle this branching?

Comment on lines 274 to 278
static int s2n_client_key_share_recv_pure_kem(
struct s2n_connection *conn,
struct s2n_stuffer *key_share,
uint16_t kem_group_iana_id)
{
Copy link
Contributor

Choose a reason for hiding this comment

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

I would expect this to mostly duplicate s2n_client_key_share_recv_pq_hybrid, which doesn't seem like the best organization. Duplicating logic means that logic can be inconsistence. Like, do we enforce that the keyshare received is in our kem preferences? s2n_client_key_share_recv_pq_hybrid does that, but s2n_client_key_share_recv_pure_kem doesn't. If that isn't being enforce anywhere, that's a huge security problem.

testing

testing

testing

testing
@johubertj johubertj force-pushed the feat/add_pure_mlkem branch from fc283cd to af21fee Compare August 19, 2025 21:52
@johubertj johubertj requested review from lrstewart and removed request for goatgoose and lrstewart August 20, 2025 19:06
@johubertj johubertj closed this Aug 20, 2025
@johubertj johubertj reopened this Aug 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants