From 8764d3f2fe8666de4278c92e06a7eaab3cfe0080 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:47:18 +0000 Subject: [PATCH 1/2] feat(tls13): offer X25519MLKEM768 by default on client and accept on server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the pure-Aether TLS 1.3 client to offer X25519MLKEM768 by default alongside classical key shares (X25519 and SECP256R1), and update the pure TLS server to accept X25519MLKEM768 key shares with FIPS 203 §7.2 encapsulation key checks. Convert connect_pq into a thin wrapper. Co-authored-by: paul-hammant <82182+paul-hammant@users.noreply.github.com> --- ...7Z-changed-tls13-x25519mlkem768-default.md | 1 + std/cryptography/tls13_client/module.ae | 289 ++++++++---------- std/cryptography/tls13_hs/module.ae | 111 ++++--- std/cryptography/tls13_server/module.ae | 106 ++++++- .../crypto_tls13_hs/test_tls13_hs.ae | 2 +- .../test_tls13_server_hs.ae | 50 +++ 6 files changed, 340 insertions(+), 219 deletions(-) create mode 100644 new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md diff --git a/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md b/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md new file mode 100644 index 000000000..7ffc83a42 --- /dev/null +++ b/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md @@ -0,0 +1 @@ +- **Offer X25519MLKEM768 by default on the pure TLS client, and accept it on the server (#2483).** The pure-Aether TLS 1.3 client now offers post-quantum hybrid key exchange by default in `connect_full` alongside classical X25519 and secp256r1. The pure TLS server accepts X25519MLKEM768, enforces FIPS 203 §7.2 encapsulation key checks, and responds with the hybrid key share. diff --git a/std/cryptography/tls13_client/module.ae b/std/cryptography/tls13_client/module.ae index 1154bc8f8..574da074a 100644 --- a/std/cryptography/tls13_client/module.ae +++ b/std/cryptography/tls13_client/module.ae @@ -720,29 +720,53 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli sock, dial_err = tcp.connect(host, port) if string.equals(dial_err, "") != 1 { return null, dial_err } } - // Initial key exchange group is x25519, using the caller's private scalar. - // On a HelloRetryRequest we may switch to secp256r1 with a fresh scalar. - cur_group = tls13_hs.GROUP_X25519 - cur_priv = x25519_priv // NOT owned here (caller's); P-256 retry owns its own - own_priv = 0 // 1 once we allocate a retry scalar we must free - // Offer BOTH an x25519 and a secp256r1 key_share up front, so a server that - // prefers P-256 completes without a HelloRetryRequest round-trip. We keep - // the P-256 ephemeral private in case the server selects secp256r1. + + // Ephemerals: ML-KEM-768 keypair for the post-quantum hybrid share + ek, dk, kerr = mlkem.mlkem768_keygen() + if kerr != "" { + if adopt_sock == null { tcp.close(sock) } + return null, kerr + } + + // Ephemerals: x25519 and secp256r1 + cur_xpriv = x25519_priv + own_xpriv = 0 + if cur_xpriv == null { + cur_xpriv = rand_into(32) + own_xpriv = 1 + } p256_priv = rand_into(32) - x_pub, x_pub_len = keyshare_for_group(tls13_hs.GROUP_X25519, x25519_priv) + + x_pub, x_pub_len = keyshare_for_group(tls13_hs.GROUP_X25519, cur_xpriv) p_pub, p_pub_len = keyshare_for_group(tls13_hs.GROUP_SECP256R1, p256_priv) - pub = x_pub // primary keyshare (x25519); cleanup frees this - _pub_len = x_pub_len + + // Build hybrid share = ML-KEM ek (1184) || X25519 pub (32) = 1216 bytes + ek_len = bytes.length(ek) + hybrid = bytes.new(ek_len + 32) + bytes.set(hybrid, ek_len + 32 - 1, 0) + bytes.copy_from_bytes(hybrid, 0, ek, 0, ek_len) + bytes.copy_from_bytes(hybrid, ek_len, x_pub, 0, 32) + rnd = rand_into(32) sid = rand_into(32) - ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519, bytes.view(x_pub)[0..x_pub_len], tls13_hs.GROUP_SECP256R1, bytes.view(p_pub)[0..p_pub_len], host) - bytes.free(p_pub) // copied into the CH; keep only p256_priv + + // Pre-offer GROUP_X25519MLKEM768 (hybrid), GROUP_X25519 (x_pub), and GROUP_SECP256R1 (p_pub) + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519MLKEM768, bytes.view(hybrid)[0..ek_len + 32], tls13_hs.GROUP_X25519, bytes.view(x_pub)[0..x_pub_len], tls13_hs.GROUP_SECP256R1, bytes.view(p_pub)[0..p_pub_len], host) + + bytes.free(p_pub) + bytes.free(hybrid) + pub = x_pub // primary x25519 pub; cleanup_hello frees it + ch_len = bytes.length(ch) tr = transcript_new() transcript_add(tr, bytes.view(ch)[0..ch_len]) serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) + bytes.free(ek); bytes.free(dk) + if own_xpriv == 1 { bytes.free(cur_xpriv) } + bytes.free(p256_priv) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) + if adopt_sock == null { tcp.close(sock) } return null, serr } rio = recio_new(sock) @@ -750,7 +774,11 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli // ServerHello (may be a HelloRetryRequest we answer once). shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) + bytes.free(ek); bytes.free(dk) + if own_xpriv == 1 { bytes.free(cur_xpriv) } + bytes.free(p256_priv) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, sherr } sh_msg_len = shlen - 5 @@ -760,8 +788,12 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0 } perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) if string.equals(perr, "") != 1 { + bytes.free(ek); bytes.free(dk) + if own_xpriv == 1 { bytes.free(cur_xpriv) } + bytes.free(p256_priv) bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, perr } @@ -773,31 +805,36 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli retry_group = parsed.group rp = null - if retry_group == tls13_hs.GROUP_SECP256R1 { + if retry_group == tls13_hs.GROUP_SECP256R1 || retry_group == tls13_hs.GROUP_X25519 { rp = rand_into(32) } else { - // Server asked to retry a group we don't support (or echoed x25519, - // which shouldn't happen after a HRR); give up. + bytes.free(ek); bytes.free(dk) + if own_xpriv == 1 { bytes.free(cur_xpriv) } + bytes.free(p256_priv) bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, "HelloRetryRequest asked for an unsupported group" } cleanup_hello(pub, rnd, sid, ch) // free CH1 + its keyshare/nonces bytes.free(sh); bytes.free(shrec) cur_group = retry_group + if own_xpriv == 1 { bytes.free(cur_xpriv) } cur_priv = rp - own_priv = 1 + own_xpriv = 1 pub2, pub2_len = keyshare_for_group(cur_group, cur_priv) rnd = rand_into(32) sid = rand_into(32) - ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], cur_group, bytes.view(pub2)[0..pub2_len], 0, null, host) + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], cur_group, bytes.view(pub2)[0..pub2_len], 0, null, 0, null, host) ch_len = bytes.length(ch) transcript_add(tr, bytes.view(ch)[0..ch_len]) serr2 = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr2, "") != 1 { + bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) cleanup_hello(pub2, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); tcp.close(sock) + transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, serr2 } pub = pub2 // so later cleanup frees the right keyshare @@ -805,8 +842,10 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli // Read the real ServerHello. shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { + bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); tcp.close(sock) + transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, sherr } sh_msg_len = shlen - 5 @@ -815,185 +854,107 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) if string.equals(perr, "") != 1 { + bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); tcp.close(sock) + transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, perr } if parsed.is_hello_retry == 1 { + bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); tcp.close(sock) + transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, "server sent a second HelloRetryRequest" } } transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) - // Select the private key matching the group the server chose. In the - // non-HRR path we offered both x25519 and secp256r1 shares; pick whichever - // the server's key_share names. (After a HRR, cur_group/cur_priv are already - // the retried group and own_priv==1.) - if own_priv == 0 { - if parsed.group == tls13_hs.GROUP_SECP256R1 { - cur_group = tls13_hs.GROUP_SECP256R1 - cur_priv = p256_priv + // Compute the shared secret based on the group selected by the server. + shared = null + shared_len = 0 + sherr2 = "" + + if parsed.group == tls13_hs.GROUP_X25519MLKEM768 { + if parsed.key_share_len != 1120 { + sherr2 = "X25519MLKEM768 server share wrong size" } else { - cur_group = tls13_hs.GROUP_X25519 - cur_priv = x25519_priv + ct = bytes.new(1088); bytes.set(ct, 1087, 0) + bytes.copy_from_bytes(ct, 0, parsed.key_share, 0, 1088) + sxpub = bytes.new(32); bytes.set(sxpub, 31, 0) + bytes.copy_from_bytes(sxpub, 0, parsed.key_share, 1088, 32) + mlk_ss, merr = mlkem.mlkem768_decaps(dk, ct) + x_ss, xerr = x25519.agree(cur_xpriv, sxpub) + if string.equals(merr, "") != 1 { + sherr2 = merr + } else if string.equals(xerr, "") != 1 { + sherr2 = xerr + } else { + shared = bytes.new(64); bytes.set(shared, 63, 0) + bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) + bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) + shared_len = 64 + } + bytes.free(ct); bytes.free(sxpub) + if mlk_ss != null { bytes.free(mlk_ss) } + if x_ss != null { bytes.free(x_ss) } } + } else if parsed.group == tls13_hs.GROUP_X25519 { + shared, sherr2 = x25519.agree(cur_xpriv, parsed.key_share) + shared_len = 32 + } else if parsed.group == tls13_hs.GROUP_SECP256R1 { + shared, sherr2 = ecdhe_shared(tls13_hs.GROUP_SECP256R1, p256_priv, parsed.key_share, parsed.key_share_len) + shared_len = 32 + } else { + sherr2 = "server chose an unoffered group" } - shared, agerr = ecdhe_shared(cur_group, cur_priv, parsed.key_share, parsed.key_share_len) - if string.equals(agerr, "") != 1 { + bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) + if own_xpriv == 1 { bytes.free(cur_xpriv) } + + if string.equals(sherr2, "") != 1 || shared == null { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); if own_priv == 1 { bytes.free(cur_priv) } - transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, agerr + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } + return null, sherr2 } + // Negotiate the record AEAD + key-schedule hash from the chosen suite. _rec_aead, _rec_key_len, _hash_algo, _hash_len, suite_err = suite_params(parsed.cipher_suite) if string.equals(suite_err, "") != 1 { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(shared); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) + if adopt_sock == null { tcp.close(sock) } return null, suite_err } - // Derive keys from the (classical) 32-byte ECDHE secret and run the shared - // post-ServerHello handshake tail (flight read, auth, Finished). - result, result_err = run_handshake_tail(sock, rio, tr, host, bytes.view(shared)[0..32], parsed.cipher_suite, client_cert, client_cert_len, client_key, insecure, cafile) + // Derive keys and run shared post-ServerHello handshake tail. + result, result_err = run_handshake_tail(sock, rio, tr, host, bytes.view(shared)[0..shared_len], parsed.cipher_suite, client_cert, client_cert_len, client_key, insecure, cafile) bytes.free(shared) if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) - if own_priv == 1 { bytes.free(cur_priv) } // P-256 retry scalar (HRR path) - bytes.free(p256_priv) // the pre-offered P-256 ephemeral if result == null { - recio_free(rio); tcp.close(sock) + recio_free(rio) + if adopt_sock == null { tcp.close(sock) } } return result, result_err } -// Post-quantum connect: offer the X25519MLKEM768 hybrid group (RFC-draft -// X25519+ML-KEM-768, the group Chrome/Cloudflare default to), with x25519 as a -// classical fallback share. Generates its own ephemerals (the hybrid private is -// a pair: an X25519 scalar + a 2400-byte ML-KEM decapsulation key). On the -// server's ServerHello it either does the hybrid key exchange (ML-KEM decaps || -// X25519 agree -> 64-byte shared secret) or, if the server fell back to x25519, -// the classical exchange. Same authentication + Finished as connect() via the -// shared tail. Returns a live ClientConn or (null, error). +// Post-quantum connect: thin wrapper around connect(), which now offers +// X25519MLKEM768 by default. connect_pq(host: string, port: int) -> (ptr, string) { - sock, dial_err = tcp.connect(host, port) - if string.equals(dial_err, "") != 1 { return null, dial_err } - - // Ephemerals: an ML-KEM-768 keypair (keep dk to decapsulate) and an X25519 - // scalar (keep to agree). keygen seeds itself from the CSPRNG and fails - // rather than hand back a key from a failed draw. - ek, dk, kerr = mlkem.mlkem768_keygen() - if kerr != "" { - tcp.close(sock) - return null, kerr - } - xpriv = rand_into(32) - xpub = x25519.base_point(xpriv) - - // Client hybrid share = ML-KEM ek(1184) || X25519 pub(32) = 1216 bytes. - ek_len = bytes.length(ek) - hybrid = bytes.new(ek_len + 32) - bytes.set(hybrid, ek_len + 32 - 1, 0) - bytes.copy_from_bytes(hybrid, 0, ek, 0, ek_len) - bytes.copy_from_bytes(hybrid, ek_len, xpub, 0, 32) - - rnd = rand_into(32) - sid = rand_into(32) - ch = tls13_hs.client_hello_pq(rnd, bytes.view(sid)[0..32], hybrid, ek_len + 32, xpub, host) - ch_len = bytes.length(ch) - tr = transcript_new() - transcript_add(tr, bytes.view(ch)[0..ch_len]) - serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) - if string.equals(serr, "") != 1 { - bytes.free(ek); bytes.free(dk); bytes.free(xpriv); bytes.free(xpub); bytes.free(hybrid) - cleanup_hello(null, rnd, sid, ch); transcript_free(tr); tcp.close(sock) - return null, serr - } - rio = recio_new(sock) - - shrec, shlen, sherr = recio_next_hs(rio) - if string.equals(sherr, "") != 1 { - bytes.free(ek); bytes.free(dk); bytes.free(xpriv); bytes.free(xpub); bytes.free(hybrid) - cleanup_hello(null, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, sherr - } - sh_msg_len = shlen - 5 - sh = bytes.new(sh_msg_len) - bytes.set(sh, sh_msg_len - 1, 0) - bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) - transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) - parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0 } - perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) - - result = null - result_err = perr - if string.equals(perr, "") == 1 { - // Compute the shared secret from the group the server selected. - shared = null - shared_len = 0 - sherr2 = "" - if parsed.group == tls13_hs.GROUP_X25519MLKEM768 { - // Server share = ML-KEM ct(1088) || X25519 pub(32) = 1120 bytes. - if parsed.key_share_len != 1120 { - sherr2 = "X25519MLKEM768 server share wrong size" - } else { - ct = bytes.new(1088); bytes.set(ct, 1087, 0) - bytes.copy_from_bytes(ct, 0, parsed.key_share, 0, 1088) - sxpub = bytes.new(32); bytes.set(sxpub, 31, 0) - bytes.copy_from_bytes(sxpub, 0, parsed.key_share, 1088, 32) - mlk_ss, merr = mlkem.mlkem768_decaps(dk, ct) - x_ss, xerr = x25519.agree(xpriv, sxpub) - if string.equals(merr, "") != 1 { - sherr2 = merr - } else if string.equals(xerr, "") != 1 { - sherr2 = xerr - } else { - // Hybrid shared secret = ML-KEM ss(32) || X25519 ss(32). - shared = bytes.new(64); bytes.set(shared, 63, 0) - bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) - bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) - shared_len = 64 - } - bytes.free(ct); bytes.free(sxpub); bytes.free(mlk_ss) - if x_ss != null { bytes.free(x_ss) } - } - } else { - if parsed.group == tls13_hs.GROUP_X25519 { - // Server fell back to classical x25519. - shared, sherr2 = x25519.agree(xpriv, parsed.key_share) - shared_len = 32 - } else { - sherr2 = "server chose an unoffered group" - } - } - - if string.equals(sherr2, "") != 1 || shared == null { - result_err = sherr2 - if string.equals(sherr2, "") == 1 { result_err = "PQ key exchange failed" } - } else { - result, result_err = run_handshake_tail(sock, rio, tr, host, bytes.view(shared)[0..shared_len], parsed.cipher_suite, null, 0, null, 0, "") - bytes.free(shared) - } - } - - bytes.free(ek); bytes.free(dk); bytes.free(xpriv); bytes.free(xpub); bytes.free(hybrid) - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(sh); bytes.free(shrec) - cleanup_hello(null, rnd, sid, ch); transcript_free(tr) - if result == null { - recio_free(rio); tcp.close(sock) - } - return result, result_err + priv = rand_into(32) + if priv == null { return null, "no randomness for key share" } + conn, err = connect(host, port, priv) + bytes.free(priv) + return conn, err } // The shared post-ServerHello handshake tail: derive handshake keys from the diff --git a/std/cryptography/tls13_hs/module.ae b/std/cryptography/tls13_hs/module.ae index 1435eaafc..57fa74b05 100644 --- a/std/cryptography/tls13_hs/module.ae +++ b/std/cryptography/tls13_hs/module.ae @@ -154,25 +154,33 @@ client_hello(random: ptr, session_id: byte[], x25519_pub: ptr) -> ptr { // (RFC 6066 §3). CDNs require SNI to select the right certificate. An empty // host omits the extension (identical to the bare client_hello). client_hello_sni(random: ptr, session_id: byte[], x25519_pub: ptr, host: string) -> ptr { - return client_hello_full(random, session_id, GROUP_X25519, bytes.view(x25519_pub)[0..32], 0, null, host) + return client_hello_full(random, session_id, GROUP_X25519, bytes.view(x25519_pub)[0..32], 0, null, 0, null, host) } -// Build a ClientHello offering both x25519 and secp256r1 in supported_groups, -// carrying a key_share for `group` (its `pubkey`/`pubkey_len`) and optionally a -// second key_share for `group2` (pass group2=0 to omit). Offering both shares -// lets a server that prefers the other group complete without a -// HelloRetryRequest. x25519 share = 32-byte public; secp256r1 share = 65-byte -// uncompressed point (0x04 || X || Y). The HRR retry path uses a single share. -client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], group2: int, pubkey2: byte[], host: string) -> ptr { +// Build a ClientHello offering up to three groups (e.g. X25519MLKEM768, x25519, +// secp256r1) in supported_groups, carrying key_shares for each offered group. +// Pass group2=0 or group3=0 to omit the respective entry. +client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], group2: int, pubkey2: byte[], group3: int, pubkey3: byte[], host: string) -> ptr { if session_id.len < 0 { panic("tls13_hs.client_hello_full: the `session_id` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int sid_len = session_id.len - if pubkey.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int pubkey_len = pubkey.len - if pubkey2.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey2` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int pubkey2_len = pubkey2.len - // Generous fixed buffer; this CH is ~230 bytes + the hostname. - buf = bytes.new(1024) - bytes.set(buf, 1023, 0) // force full zero-fill + pubkey_len = 0 + if group != 0 { + if pubkey.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + pubkey_len = pubkey.len + } + pubkey2_len = 0 + if group2 != 0 { + if pubkey2.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey2` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + pubkey2_len = pubkey2.len + } + pubkey3_len = 0 + if group3 != 0 { + if pubkey3.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey3` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + pubkey3_len = pubkey3.len + } + // Generous fixed buffer for up to 3 key shares + extensions. + buf = bytes.new(2048) + bytes.set(buf, 2047, 0) // force full zero-fill // Reserve the 4-byte handshake header; fill length at the end. body = 4 @@ -222,13 +230,19 @@ client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], g off = put8(buf, off, 2) // list len off = put16(buf, off, 0x0304) // TLS 1.3 - // supported_groups (10): { x25519, secp256r1 } — offer both so a server - // that won't do x25519 can HelloRetryRequest secp256r1. + // supported_groups (10): list all offered groups + num_groups = 0 + if group != 0 { num_groups = num_groups + 1 } + if group2 != 0 { num_groups = num_groups + 1 } + if group3 != 0 { num_groups = num_groups + 1 } + + sg_body = 2 + num_groups * 2 off = put16(buf, off, 10) - off = put16(buf, off, 6) // ext body len = 2 (list len) + 2*2 - off = put16(buf, off, 4) // list len - off = put16(buf, off, GROUP_X25519) - off = put16(buf, off, GROUP_SECP256R1) + off = put16(buf, off, sg_body) // ext body len + off = put16(buf, off, num_groups * 2) // list len + if group != 0 { off = put16(buf, off, group) } + if group2 != 0 { off = put16(buf, off, group2) } + if group3 != 0 { off = put16(buf, off, group3) } // signature_algorithms (13): // { ecdsa_p256_sha256, ecdsa_p384_sha384, rsa_pss_rsae_sha256, ed25519 } @@ -260,23 +274,30 @@ client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], g off = put8(buf, off, 8) // ProtocolName length off = put_ascii(buf, off, "http/1.1") - // key_share (51): a KeyShareEntry {group, key} for `group`, plus an - // optional second entry (group2/pubkey2) so we can pre-offer both x25519 and - // secp256r1 and let the server pick without a HelloRetryRequest round-trip. - entry1 = 2 + 2 + pubkey_len - shares_len = entry1 + // key_share (51): a KeyShareEntry {group, key} for each offered group. + shares_len = 0 + if group != 0 { shares_len = shares_len + 2 + 2 + pubkey_len } if group2 != 0 { shares_len = shares_len + 2 + 2 + pubkey2_len } + if group3 != 0 { shares_len = shares_len + 2 + 2 + pubkey3_len } + off = put16(buf, off, 51) off = put16(buf, off, 2 + shares_len) // ext body: client_shares<2> + entries off = put16(buf, off, shares_len) // client_shares length - off = put16(buf, off, group) - off = put16(buf, off, pubkey_len) - off = put_bytes(buf, off, pubkey) + if group != 0 { + off = put16(buf, off, group) + off = put16(buf, off, pubkey_len) + off = put_bytes(buf, off, pubkey) + } if group2 != 0 { off = put16(buf, off, group2) off = put16(buf, off, pubkey2_len) off = put_bytes(buf, off, pubkey2) } + if group3 != 0 { + off = put16(buf, off, group3) + off = put16(buf, off, pubkey3_len) + off = put_bytes(buf, off, pubkey3) + } // fill extensions length ext_total = off - ext_start @@ -731,7 +752,8 @@ parse_client_hello(msg: byte[], out: *ChParsed) -> string { } if ext_type == 0x0033 { // key_share: client_shares<0..2^16-1> of { group(2), len(2), key }. - // Take the first entry whose group we can complete. + // Prefer post-quantum X25519MLKEM768 (1216 bytes) if offered, + // otherwise fallback to classical X25519 (32 bytes). if ext_len >= 2 { ks_total = get16(msg, d) q = d + 2 @@ -742,15 +764,26 @@ parse_client_hello(msg: byte[], out: *ChParsed) -> string { klen = get16(msg, q + 2) kstart = q + 4 if kstart + klen > ks_end { q = ks_end } else { - if out.group == 0 { - if g == GROUP_X25519 { - if klen == 32 { - kb = bytes.new(klen) - bytes.copy_from_slice(kb, 0, msg[kstart..kstart + klen]) - out.group = g - out.key_share = kb - out.key_share_len = klen - } + if g == GROUP_X25519MLKEM768 { + if klen != 1216 { + return "client hello: invalid X25519MLKEM768 key share length" + } + if out.key_share != null { + bytes.free(out.key_share) + out.key_share = null + } + kb = bytes.new(klen) + bytes.copy_from_slice(kb, 0, msg[kstart..kstart + klen]) + out.group = g + out.key_share = kb + out.key_share_len = klen + } else if g == GROUP_X25519 && out.group == 0 { + if klen == 32 { + kb = bytes.new(klen) + bytes.copy_from_slice(kb, 0, msg[kstart..kstart + klen]) + out.group = g + out.key_share = kb + out.key_share_len = klen } } q = kstart + klen diff --git a/std/cryptography/tls13_server/module.ae b/std/cryptography/tls13_server/module.ae index fe9ce04a4..28e2b24e2 100644 --- a/std/cryptography/tls13_server/module.ae +++ b/std/cryptography/tls13_server/module.ae @@ -11,9 +11,7 @@ // key fails at the signature step and the peer just sees the connection // drop. Generate with: // openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -// - X25519 key exchange only. A client offering only other groups is -// refused by parse_client_hello; HelloRetryRequest is not implemented, -// so there is no renegotiation to a group we do support. +// - Key exchange: X25519MLKEM768 (post-quantum hybrid) and X25519. // - Buffer arguments are std.bytes HANDLES, never bytes.data(b) -- see the // header of tls13_client for why that distinction bites. @@ -27,6 +25,7 @@ import std.tcp import std.cryptography.sha2 import std.cryptography.hmac import std.cryptography.x25519 +import std.cryptography.mlkem import std.cryptography.p256 import std.cryptography.tls13_kdf import std.cryptography.tls13_client @@ -190,6 +189,19 @@ fn send_record_raw(sock: ptr, rec: ptr) -> string { return "" } +fn send_plaintext_alert(sock: ptr, level: int, desc: int) { + b = bytes.new(7) + bytes.set(b, 0, 0x15) // alert + bytes.set(b, 1, 0x03) + bytes.set(b, 2, 0x03) // legacy record version 0x0303 + bytes.set(b, 3, 0x00) + bytes.set(b, 4, 0x02) // length 2 + bytes.set(b, 5, level & 0xFF) + bytes.set(b, 6, desc & 0xFF) + srv_send_view(sock, bytes.view(b)[0..7]) + bytes.free(b) +} + // Fresh randomness, or null. Returning null on failure is deliberate and the // callers MUST check: every use here is security-critical -- the server // random, the ECDSA-P256 signing nonce, and the X25519 private key. Silently @@ -302,6 +314,7 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p has_sni: 0, saw_supported_versions: 0 } perr = tls13_hs.parse_client_hello(bytes.view(ch_msg)[0..ch_msg_len], &ch_parsed) if string.equals(perr, "") != 1 { + send_plaintext_alert(sock, 2, 47) // illegal_parameter bytes.free(ch_msg); recio_free(rio) return null, perr } @@ -309,27 +322,89 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p tr = tls13_client.transcript_new() tls13_client.transcript_add(tr, bytes.view(ch_msg)[0..ch_msg_len]) - // 2. Compute X25519 shared secret - shared, agerr = x25519.agree(x25519_priv, ch_parsed.key_share) - if string.equals(agerr, "") != 1 { + // 2. Compute shared secret & server key share + shared = null + shared_len = 0 + server_ks = null + server_ks_len = 0 + + if ch_parsed.group == tls13_hs.GROUP_X25519MLKEM768 { + if ch_parsed.key_share_len != 1216 { + send_plaintext_alert(sock, 2, 47) + tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + return null, "X25519MLKEM768 key share wrong length" + } + ek = bytes.new(1184) + bytes.copy_from_bytes(ek, 0, ch_parsed.key_share, 0, 1184) + cxpub = bytes.new(32) + bytes.copy_from_bytes(cxpub, 0, ch_parsed.key_share, 1184, 32) + + ct, mlk_ss, merr = mlkem.mlkem768_encaps(ek) + bytes.free(ek) + if string.equals(merr, "") != 1 || ct == null || mlk_ss == null { + if ct != null { bytes.free(ct) } + if mlk_ss != null { bytes.free(mlk_ss) } + bytes.free(cxpub) + send_plaintext_alert(sock, 2, 47) // illegal_parameter (RFC 10024 §4.2) + tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + return null, string.concat("X25519MLKEM768 encapsulation failed: ", merr) + } + + x_ss, xerr = x25519.agree(x25519_priv, cxpub) + bytes.free(cxpub) + if string.equals(xerr, "") != 1 || x_ss == null { + if x_ss != null { bytes.free(x_ss) } + bytes.free(ct); bytes.free(mlk_ss) + send_plaintext_alert(sock, 2, 47) + tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + return null, xerr + } + + sxpub = x25519.base_point(x25519_priv) + server_ks = bytes.new(1120) + bytes.copy_from_bytes(server_ks, 0, ct, 0, 1088) + bytes.copy_from_bytes(server_ks, 1088, sxpub, 0, 32) + server_ks_len = 1120 + bytes.free(ct); bytes.free(sxpub) + + shared = bytes.new(64) + bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) + bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) + shared_len = 64 + bytes.free(mlk_ss); bytes.free(x_ss) + } else if ch_parsed.group == tls13_hs.GROUP_X25519 { + x_ss, xerr = x25519.agree(x25519_priv, ch_parsed.key_share) + if string.equals(xerr, "") != 1 || x_ss == null { + if x_ss != null { bytes.free(x_ss) } + send_plaintext_alert(sock, 2, 40) // handshake_failure + tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + return null, xerr + } + sxpub = x25519.base_point(x25519_priv) + server_ks = sxpub + server_ks_len = 32 + shared = x_ss + shared_len = 32 + } else { + send_plaintext_alert(sock, 2, 40) tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) - return null, agerr + return null, "unsupported group in client hello" } - x25519_pub = x25519.base_point(x25519_priv) // 3. Build and send ServerHello (plaintext) srnd = rand_into(32) if srnd == null { tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg) tls13_client.transcript_free(tr); recio_free(rio) - bytes.free(x25519_pub); bytes.free(shared) + bytes.free(server_ks); bytes.free(shared) return null, "no randomness available for the server random" } - sh = tls13_hs.server_hello(srnd, bytes.view(ch_parsed.session_id)[0..ch_parsed.session_id_len], ch_parsed.cipher_suite, ch_parsed.group, bytes.view(x25519_pub)[0..32]) + sh = tls13_hs.server_hello(srnd, bytes.view(ch_parsed.session_id)[0..ch_parsed.session_id_len], ch_parsed.cipher_suite, ch_parsed.group, bytes.view(server_ks)[0..server_ks_len]) + bytes.free(server_ks) sh_len = bytes.length(sh) sherr = send_plaintext_handshake(sock, bytes.view(sh)[0..sh_len]) if string.equals(sherr, "") != 1 { - bytes.free(srnd); bytes.free(x25519_pub); bytes.free(shared); bytes.free(sh) + bytes.free(srnd); bytes.free(shared); bytes.free(sh) tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, sherr } @@ -338,13 +413,14 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p // 4. Key Schedule rec_aead, rec_key_len, hash_algo, hash_len, suite_err = suite_params(ch_parsed.cipher_suite) if string.equals(suite_err, "") != 1 { - bytes.free(srnd); bytes.free(x25519_pub); bytes.free(shared); bytes.free(sh) + bytes.free(srnd); bytes.free(shared); bytes.free(sh) tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, suite_err } th_chsh = transcript_hash_algo(tr, hash_algo) - keys = derive_handshake_keys_algo(bytes.view(shared)[0..32], bytes.view(th_chsh)[0..hash_len], rec_key_len, hash_algo) + keys = derive_handshake_keys_algo(bytes.view(shared)[0..shared_len], bytes.view(th_chsh)[0..hash_len], rec_key_len, hash_algo) + bytes.free(shared) srv_hs_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_server_key(keys))[0..rec_key_len], hs_server_iv(keys)) cli_hs_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) @@ -419,7 +495,7 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p } if fin_ok == 0 { - bytes.free(th_sf); bytes.free(srnd); bytes.free(x25519_pub); bytes.free(shared); bytes.free(sh) + bytes.free(th_sf); bytes.free(srnd); bytes.free(sh) tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) tls13_record.free_ctx(srv_hs_ctx); tls13_record.free_ctx(cli_hs_ctx) tls13_client.free_hs_keys(keys); bytes.free(th_chsh) @@ -442,7 +518,7 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p // Cleanup handshake state bytes.free(ck); bytes.free(civ); bytes.free(sk); bytes.free(siv) bytes.free(ms); bytes.free(c_ap); bytes.free(s_ap); bytes.free(th_sf) - bytes.free(srnd); bytes.free(x25519_pub); bytes.free(shared); bytes.free(sh) + bytes.free(srnd); bytes.free(sh) tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr) tls13_record.free_ctx(srv_hs_ctx); tls13_record.free_ctx(cli_hs_ctx) tls13_client.free_hs_keys(keys); bytes.free(th_chsh) diff --git a/tests/integration/crypto_tls13_hs/test_tls13_hs.ae b/tests/integration/crypto_tls13_hs/test_tls13_hs.ae index 39e3218b1..62ad199ec 100644 --- a/tests/integration/crypto_tls13_hs/test_tls13_hs.ae +++ b/tests/integration/crypto_tls13_hs/test_tls13_hs.ae @@ -204,7 +204,7 @@ main() { p256pt_i = 0 while p256pt_i < 65 { bytes.set(p256pt, p256pt_i, 0x11); p256pt_i = p256pt_i + 1 } bytes.set(p256pt, 0, 0x04) - dual = tls13_hs.client_hello_full(random, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], tls13_hs.GROUP_SECP256R1, bytes.view(p256pt)[0..65], "example.com") + dual = tls13_hs.client_hello_full(random, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], tls13_hs.GROUP_SECP256R1, bytes.view(p256pt)[0..65], 0, null, "example.com") // The CH must be a valid handshake message and larger than a single-share // CH (it carries an extra 4 + 65 = 69-byte key_share entry + SNI). if g8(dual, 0) == 1 { diff --git a/tests/integration/crypto_tls13_server_hs/test_tls13_server_hs.ae b/tests/integration/crypto_tls13_server_hs/test_tls13_server_hs.ae index 6fed40a8b..9789d7772 100644 --- a/tests/integration/crypto_tls13_server_hs/test_tls13_server_hs.ae +++ b/tests/integration/crypto_tls13_server_hs/test_tls13_server_hs.ae @@ -14,6 +14,7 @@ import std.cryptography.tls13_hs import std.bytes +import std.string extern exit(code: int) @@ -240,6 +241,55 @@ main() { bytes.free(sig) println("ok the server flight builders emit well-formed messages") + // ---- 6. parse_client_hello with X25519MLKEM768 ---- + r6 = filled(32, 1) + s6 = filled(32, 9) + p6 = filled(32, 50) + hy = filled(1216, 7) + pqch = tls13_hs.client_hello_pq(r6, bytes.view(s6)[0..32], hy, 1216, p6, "example.com") + pqn = bytes.length(pqch) + pqc = ChParsed { random: null, session_id: null, session_id_len: 0, + cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, + has_sni: 0, saw_supported_versions: 0 } + pqerr = tls13_hs.parse_client_hello(bytes.view(pqch)[0..pqn], &pqc) + if pqerr != "" { + println("FAIL 6: parse_client_hello rejected PQ ClientHello: ${pqerr}") + fails = fails + 1 + } else { + if pqc.group != tls13_hs.GROUP_X25519MLKEM768 { + println("FAIL 6: group ${pqc.group}, want 0x11EC") + fails = fails + 1 + } + if pqc.key_share_len != 1216 { + println("FAIL 6: key_share_len ${pqc.key_share_len}, want 1216") + fails = fails + 1 + } + } + if pqerr == "" { println("ok parse_client_hello prefers X25519MLKEM768 group") } + tls13_hs.free_ch(&pqc) + bytes.free(pqch) + bytes.free(hy) + + bad_hy = filled(500, 3) + bad_pqch = tls13_hs.client_hello_pq(r6, bytes.view(s6)[0..32], bad_hy, 500, p6, "example.com") + bad_pqn = bytes.length(bad_pqch) + bad_pqc = ChParsed { random: null, session_id: null, session_id_len: 0, + cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, + has_sni: 0, saw_supported_versions: 0 } + bad_pqerr = tls13_hs.parse_client_hello(bytes.view(bad_pqch)[0..bad_pqn], &bad_pqc) + if string.contains(bad_pqerr, "invalid X25519MLKEM768") == 1 { + println("ok parse_client_hello rejects invalid X25519MLKEM768 share length") + } else { + println("FAIL 6: expected invalid length error, got '${bad_pqerr}'") + fails = fails + 1 + } + tls13_hs.free_ch(&bad_pqc) + bytes.free(bad_pqch) + bytes.free(bad_hy) + bytes.free(r6) + bytes.free(s6) + bytes.free(p6) + if fails != 0 { println("tls13_server_hs: ${fails} failure(s)") exit(1) From 82b72d58ddc8be2ff46f69304b3ee24bf963d9b4 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sat, 24 Oct 2026 23:04:36 +0100 Subject: [PATCH 2/2] Pure TLS X25519MLKEM768: fix the retry and socket paths, test it end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on Jules's commit for #2483. Client (tls13_client.connect_full): - A HelloRetryRequest now ends the handshake. Every group we support already has a key share in the ClientHello, so the server can only name one of them, which RFC 8446 §4.1.4 says to abort on. The retry path this replaces freed the caller's x25519 scalar, then derived the P-256 secret from the wrong key. - Failures close the adopted socket again. std.http hands its descriptor over and relies on the pure client to close it on every failure (aether_http.c); skipping the close leaked the descriptor. - The hybrid key exchange moves into hybrid_shared(). The new conn_group(conn) reports the negotiated group. Server (tls13_server.accept): - An all-zero x25519 result gets illegal_parameter (RFC 8446 §7.4.2), not handshake_failure. - A refused ClientHello frees what parse_client_hello allocated and is answered with an alert that fits: illegal_parameter for a bad hybrid share, handshake_failure when no group or suite is shared, protocol_version, or decode_error. Tests (https_pure_tls_vertical): - The pure client and pure server must negotiate group 4588. - A new bad_share probe sends a share that fails the FIPS 203 §7.2 modulus check, and one of the wrong length. Each must get a fatal illegal_parameter alert. - Negative controls: with main's server the group check fails (29). Without the §7.2 check, the probe gets a ServerHello. Co-Authored-By: Claude Opus 5.5 --- ...7Z-changed-tls13-x25519mlkem768-default.md | 16 +- std/cryptography/tls13_client/module.ae | 289 ++++++------------ std/cryptography/tls13_server/module.ae | 20 +- std/http/README.md | 12 +- .../https_pure_tls_vertical/bad_share.ae | 107 +++++++ .../https_pure_tls_vertical/client.ae | 3 + .../test_https_pure_tls_vertical.sh | 20 +- 7 files changed, 265 insertions(+), 202 deletions(-) create mode 100644 tests/integration/https_pure_tls_vertical/bad_share.ae diff --git a/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md b/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md index 7ffc83a42..5b5bf6724 100644 --- a/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md +++ b/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md @@ -1 +1,15 @@ -- **Offer X25519MLKEM768 by default on the pure TLS client, and accept it on the server (#2483).** The pure-Aether TLS 1.3 client now offers post-quantum hybrid key exchange by default in `connect_full` alongside classical X25519 and secp256r1. The pure TLS server accepts X25519MLKEM768, enforces FIPS 203 §7.2 encapsulation key checks, and responds with the hybrid key share. +- **The pure TLS client offers X25519MLKEM768 by default, and the pure server + accepts it (#2483).** Every ClientHello from `std.cryptography.tls13_client` + now carries an X25519MLKEM768 key share (the post-quantum hybrid, RFC 10024) + beside the x25519 and secp256r1 ones, so `connect()`, `connect_mtls()` and + the `std.http.client` fallback in builds without OpenSSL get post-quantum key + exchange from any server that supports it. The ClientHello grows by about + 1.2 KB. `connect_pq()` is now `connect()` with a fresh scalar, and the new + `conn_group(conn)` reports the group the server chose. The pure server + (`std.cryptography.tls13_server`, used by `AETHER_PURE_TLS=1`) prefers the + hybrid when a client offers it. It answers a share that fails the FIPS 203 + §7.2 check, or has the wrong length, with a fatal `illegal_parameter` alert, + and now sends an alert for any refused ClientHello instead of dropping the + connection. A HelloRetryRequest now ends the handshake: the client already + sends a share for every group it supports, so any retry the server asks for + is one RFC 8446 §4.1.4 says to abort on. diff --git a/std/cryptography/tls13_client/module.ae b/std/cryptography/tls13_client/module.ae index 574da074a..1c4cba519 100644 --- a/std/cryptography/tls13_client/module.ae +++ b/std/cryptography/tls13_client/module.ae @@ -9,8 +9,9 @@ // and sha2 (transcript hash). // // Subset (matches #1298): -// - TLS 1.3 only; x25519 + secp256r1 key exchange, plus post-quantum -// X25519MLKEM768 via connect_pq() +// - TLS 1.3 only; key shares for X25519MLKEM768 (post-quantum hybrid, +// RFC 10024), x25519 and secp256r1 in every ClientHello, so the server +// picks without a HelloRetryRequest; conn_group() reports its choice // - cipher suites: TLS_CHACHA20_POLY1305_SHA256 and TLS_AES_128_GCM_SHA256 // (negotiated from the ServerHello) // - server CertificateVerify: ECDSA-P256, ECDSA-P384, or RSA-PSS-rsae-SHA256 @@ -133,7 +134,7 @@ exports( finished_key, finished_verify_data, hs_msg_type, hs_msg_len, hs_msg_total, HS_ENCRYPTED_EXTENSIONS, HS_CERTIFICATE, HS_CERTIFICATE_VERIFY, HS_FINISHED, - connect, connect_mtls, connect_pq, connect_resume, connect_resume_0rtt, close_conn, conn_send, conn_recv, + connect, connect_mtls, connect_pq, conn_group, connect_resume, connect_resume_0rtt, close_conn, conn_send, conn_recv, conn_get_ticket, free_ticket, SessionTicket, ticket_len, ticket_nonce_len, ticket_lifetime, ticket_max_early_data, verify_flight_auth, @@ -552,6 +553,7 @@ struct ClientConn { res_master: ptr // resumption_master_secret (Hash.len) or null res_hash_algo: ptr // hash name as bytes ("sha256"/"sha384") or null res_hash_len: int + group: int // the key exchange group negotiated (conn_group) } // Send the record `rec` on `sock`, a borrowed view of its buffer with no @@ -601,35 +603,6 @@ fn cleanup_hello(pub: ptr, rnd: ptr, sid: ptr, ch: ptr) { bytes.free(pub); bytes.free(rnd); bytes.free(sid); bytes.free(ch) } -// The key-schedule hash name for a cipher suite (without the full suite_params -// tuple), used during the HRR transcript rewrite before AEAD selection. -fn hash_algo_for_suite(cipher_suite: int) -> string { - if cipher_suite == tls13_hs.TLS_AES_256_GCM_SHA384 { return "sha384" } - return "sha256" -} - -// RFC 8446 §4.4.1: after a HelloRetryRequest the transcript's first ClientHello -// is replaced by a synthetic "message_hash" handshake message wrapping the hash -// of CH1: 0xFE || 0x00 0x00 || Hash.len || Hash(CH1). The transcript currently -// holds exactly CH1; reset it to that synthetic message. -fn rewrite_transcript_for_hrr(tr: ptr, ch1: ptr, ch1_len: int, hash_algo: string) { - ch1_hash = transcript_hash_algo(tr, hash_algo) // Hash(CH1) — tr holds CH1 only - hlen = bytes.length(ch1_hash) - synthetic = bytes.new(4 + hlen) - bytes.set(synthetic, 4 + hlen - 1, 0) - bytes.set(synthetic, 0, 0xFE) // handshake type: message_hash - bytes.set(synthetic, 1, 0) - bytes.set(synthetic, 2, 0) - bytes.set(synthetic, 3, hlen & 0xFF) - bytes.copy_from_bytes(synthetic, 4, ch1_hash, 0, hlen) - // Reset the accumulator to just the synthetic message. - tt = tr as *Transcript - tt.len = 0 - transcript_add(tr, bytes.view(synthetic)[0..4 + hlen]) - bytes.free(ch1_hash) - bytes.free(synthetic) -} - // Build the client key_share public value for `group` from a 32-byte private // scalar. x25519 -> the 32-byte public; secp256r1 -> 04 || X(32) || Y(32) (the // 65-byte uncompressed point). Returns (pubkey, pubkey_len). @@ -721,64 +694,48 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli if string.equals(dial_err, "") != 1 { return null, dial_err } } - // Ephemerals: ML-KEM-768 keypair for the post-quantum hybrid share + // Offer every group we implement, each with a key share, so the server + // can pick one without a HelloRetryRequest round trip (#2483): + // X25519MLKEM768 first (the hybrid post-quantum group, RFC 10024), then + // x25519 and secp256r1. The hybrid share reuses the caller's x25519 + // scalar, as Chrome and Go do, so one X25519 public covers both entries. ek, dk, kerr = mlkem.mlkem768_keygen() if kerr != "" { - if adopt_sock == null { tcp.close(sock) } + tcp.close(sock) return null, kerr } - - // Ephemerals: x25519 and secp256r1 - cur_xpriv = x25519_priv - own_xpriv = 0 - if cur_xpriv == null { - cur_xpriv = rand_into(32) - own_xpriv = 1 - } p256_priv = rand_into(32) - - x_pub, x_pub_len = keyshare_for_group(tls13_hs.GROUP_X25519, cur_xpriv) + x_pub, x_pub_len = keyshare_for_group(tls13_hs.GROUP_X25519, x25519_priv) p_pub, p_pub_len = keyshare_for_group(tls13_hs.GROUP_SECP256R1, p256_priv) - // Build hybrid share = ML-KEM ek (1184) || X25519 pub (32) = 1216 bytes + // Client hybrid share = ML-KEM ek (1184) || X25519 pub (32) = 1216 bytes. ek_len = bytes.length(ek) hybrid = bytes.new(ek_len + 32) bytes.set(hybrid, ek_len + 32 - 1, 0) bytes.copy_from_bytes(hybrid, 0, ek, 0, ek_len) bytes.copy_from_bytes(hybrid, ek_len, x_pub, 0, 32) + bytes.free(ek) // dk carries what decapsulation needs rnd = rand_into(32) sid = rand_into(32) - - // Pre-offer GROUP_X25519MLKEM768 (hybrid), GROUP_X25519 (x_pub), and GROUP_SECP256R1 (p_pub) ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519MLKEM768, bytes.view(hybrid)[0..ek_len + 32], tls13_hs.GROUP_X25519, bytes.view(x_pub)[0..x_pub_len], tls13_hs.GROUP_SECP256R1, bytes.view(p_pub)[0..p_pub_len], host) - - bytes.free(p_pub) - bytes.free(hybrid) - pub = x_pub // primary x25519 pub; cleanup_hello frees it - + bytes.free(p_pub); bytes.free(hybrid) // copied into the CH + pub = x_pub // cleanup_hello frees this ch_len = bytes.length(ch) tr = transcript_new() transcript_add(tr, bytes.view(ch)[0..ch_len]) serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { - bytes.free(ek); bytes.free(dk) - if own_xpriv == 1 { bytes.free(cur_xpriv) } - bytes.free(p256_priv) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) - if adopt_sock == null { tcp.close(sock) } + bytes.free(dk); bytes.free(p256_priv) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) return null, serr } rio = recio_new(sock) - // ServerHello (may be a HelloRetryRequest we answer once). shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { - bytes.free(ek); bytes.free(dk) - if own_xpriv == 1 { bytes.free(cur_xpriv) } - bytes.free(p256_priv) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } + bytes.free(dk); bytes.free(p256_priv) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, sherr } sh_msg_len = shlen - 5 @@ -787,153 +744,57 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0 } perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) + if string.equals(perr, "") == 1 && parsed.is_hello_retry == 1 { + // Every group we implement already has a key share in this + // ClientHello, so a HelloRetryRequest names either one of them, which + // RFC 8446 §4.1.4 says the client MUST abort on, or a group we cannot + // do. + perr = "server sent a HelloRetryRequest, but every group we support was already offered" + } if string.equals(perr, "") != 1 { - bytes.free(ek); bytes.free(dk) - if own_xpriv == 1 { bytes.free(cur_xpriv) } - bytes.free(p256_priv) + bytes.free(dk); bytes.free(p256_priv) + if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, perr } - if parsed.is_hello_retry == 1 { - // RFC 8446 §4.4.1: replace CH1 in the transcript with the synthetic - // message_hash(Hash(CH1)), then append the HRR and the second CH. - rewrite_transcript_for_hrr(tr, ch, ch_len, hash_algo_for_suite(parsed.cipher_suite)) - transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) - - retry_group = parsed.group - rp = null - if retry_group == tls13_hs.GROUP_SECP256R1 || retry_group == tls13_hs.GROUP_X25519 { - rp = rand_into(32) - } else { - bytes.free(ek); bytes.free(dk) - if own_xpriv == 1 { bytes.free(cur_xpriv) } - bytes.free(p256_priv) - bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } - return null, "HelloRetryRequest asked for an unsupported group" - } - cleanup_hello(pub, rnd, sid, ch) // free CH1 + its keyshare/nonces - bytes.free(sh); bytes.free(shrec) - - cur_group = retry_group - if own_xpriv == 1 { bytes.free(cur_xpriv) } - cur_priv = rp - own_xpriv = 1 - pub2, pub2_len = keyshare_for_group(cur_group, cur_priv) - rnd = rand_into(32) - sid = rand_into(32) - ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], cur_group, bytes.view(pub2)[0..pub2_len], 0, null, 0, null, host) - ch_len = bytes.length(ch) - transcript_add(tr, bytes.view(ch)[0..ch_len]) - serr2 = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) - if string.equals(serr2, "") != 1 { - bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) - cleanup_hello(pub2, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } - return null, serr2 - } - pub = pub2 // so later cleanup frees the right keyshare - - // Read the real ServerHello. - shrec, shlen, sherr = recio_next_hs(rio) - if string.equals(sherr, "") != 1 { - bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) - cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } - return null, sherr - } - sh_msg_len = shlen - 5 - sh = bytes.new(sh_msg_len) - bytes.set(sh, sh_msg_len - 1, 0) - bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) - perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) - if string.equals(perr, "") != 1 { - bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) - bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } - return null, perr - } - if parsed.is_hello_retry == 1 { - bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) - bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } - return null, "server sent a second HelloRetryRequest" - } - } - transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) - // Compute the shared secret based on the group selected by the server. + // The shared secret for the group the server chose. shared = null - shared_len = 0 - sherr2 = "" - + shared_len = 32 + agerr = "" if parsed.group == tls13_hs.GROUP_X25519MLKEM768 { - if parsed.key_share_len != 1120 { - sherr2 = "X25519MLKEM768 server share wrong size" - } else { - ct = bytes.new(1088); bytes.set(ct, 1087, 0) - bytes.copy_from_bytes(ct, 0, parsed.key_share, 0, 1088) - sxpub = bytes.new(32); bytes.set(sxpub, 31, 0) - bytes.copy_from_bytes(sxpub, 0, parsed.key_share, 1088, 32) - mlk_ss, merr = mlkem.mlkem768_decaps(dk, ct) - x_ss, xerr = x25519.agree(cur_xpriv, sxpub) - if string.equals(merr, "") != 1 { - sherr2 = merr - } else if string.equals(xerr, "") != 1 { - sherr2 = xerr - } else { - shared = bytes.new(64); bytes.set(shared, 63, 0) - bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) - bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) - shared_len = 64 - } - bytes.free(ct); bytes.free(sxpub) - if mlk_ss != null { bytes.free(mlk_ss) } - if x_ss != null { bytes.free(x_ss) } - } - } else if parsed.group == tls13_hs.GROUP_X25519 { - shared, sherr2 = x25519.agree(cur_xpriv, parsed.key_share) - shared_len = 32 - } else if parsed.group == tls13_hs.GROUP_SECP256R1 { - shared, sherr2 = ecdhe_shared(tls13_hs.GROUP_SECP256R1, p256_priv, parsed.key_share, parsed.key_share_len) - shared_len = 32 + shared, agerr = hybrid_shared(dk, x25519_priv, parsed.key_share, parsed.key_share_len) + shared_len = 64 + } else if parsed.group == tls13_hs.GROUP_X25519 || parsed.group == tls13_hs.GROUP_SECP256R1 { + priv = x25519_priv + if parsed.group == tls13_hs.GROUP_SECP256R1 { priv = p256_priv } + shared, agerr = ecdhe_shared(parsed.group, priv, parsed.key_share, parsed.key_share_len) } else { - sherr2 = "server chose an unoffered group" + agerr = "server chose a group we did not offer" } - - bytes.free(ek); bytes.free(dk); bytes.free(p256_priv) - if own_xpriv == 1 { bytes.free(cur_xpriv) } - - if string.equals(sherr2, "") != 1 || shared == null { + bytes.free(dk); bytes.free(p256_priv) + if string.equals(agerr, "") != 1 || shared == null { + if string.equals(agerr, "") == 1 { agerr = "key exchange failed" } if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } - return null, sherr2 + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) + return null, agerr } - // Negotiate the record AEAD + key-schedule hash from the chosen suite. _rec_aead, _rec_key_len, _hash_algo, _hash_len, suite_err = suite_params(parsed.cipher_suite) if string.equals(suite_err, "") != 1 { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(shared); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio) - if adopt_sock == null { tcp.close(sock) } + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, suite_err } - // Derive keys and run shared post-ServerHello handshake tail. + // Derive keys from the shared secret (32 bytes classical, 64 hybrid) and + // run the shared post-ServerHello handshake tail (flight read, auth, + // Finished). result, result_err = run_handshake_tail(sock, rio, tr, host, bytes.view(shared)[0..shared_len], parsed.cipher_suite, client_cert, client_cert_len, client_key, insecure, cafile) bytes.free(shared) @@ -941,17 +802,57 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) if result == null { - recio_free(rio) - if adopt_sock == null { tcp.close(sock) } + recio_free(rio); tcp.close(sock) + } else { + rc = result as *ClientConn + rc.group = parsed.group } return result, result_err } -// Post-quantum connect: thin wrapper around connect(), which now offers -// X25519MLKEM768 by default. +// X25519MLKEM768 shared secret (RFC 10024): the server share is the ML-KEM +// ciphertext (1088) followed by its X25519 public (32), and the secret is the +// ML-KEM shared secret followed by the X25519 one, 64 bytes. ML-KEM comes +// first in both, the reverse of the group's name. +fn hybrid_shared(dk: ptr, x25519_priv: ptr, server_ks: ptr, server_ks_len: int) -> (ptr, string) { + if server_ks_len != 1120 { return null, "X25519MLKEM768 server share is not 1120 bytes" } + ct = bytes.new(1088); bytes.set(ct, 1087, 0) + bytes.copy_from_bytes(ct, 0, server_ks, 0, 1088) + sxpub = bytes.new(32); bytes.set(sxpub, 31, 0) + bytes.copy_from_bytes(sxpub, 0, server_ks, 1088, 32) + mlk_ss, merr = mlkem.mlkem768_decaps(dk, ct) + bytes.free(ct) + if merr != "" { + bytes.free(sxpub) + return null, merr + } + x_ss, xerr = ecdhe_shared(tls13_hs.GROUP_X25519, x25519_priv, sxpub, 32) + bytes.free(sxpub) + if xerr != "" || x_ss == null { + bytes.free(mlk_ss) + if x_ss != null { bytes.free(x_ss) } + if xerr == "" { xerr = "x25519 agreement failed" } + return null, xerr + } + shared = bytes.new(64); bytes.set(shared, 63, 0) + bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) + bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) + bytes.free(mlk_ss); bytes.free(x_ss) + return shared, "" +} + +// The key exchange group the handshake settled on: tls13_hs.GROUP_X25519MLKEM768 +// (0x11EC), GROUP_X25519 (0x001D) or GROUP_SECP256R1 (0x0017). +conn_group(conn: ptr) -> int { + if conn == null { return 0 } + c = conn as *ClientConn + return c.group +} + +// Post-quantum connect, kept for callers written before connect() offered +// X25519MLKEM768 itself (#2483): connect() with a fresh x25519 scalar. connect_pq(host: string, port: int) -> (ptr, string) { priv = rand_into(32) - if priv == null { return null, "no randomness for key share" } conn, err = connect(host, port, priv) bytes.free(priv) return conn, err @@ -1120,6 +1021,7 @@ fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, sh conn.res_master = res_master conn.res_hash_algo = str_to_bytes_local(hash_algo) conn.res_hash_len = hash_len + conn.group = 0 // connect_full sets it result = conn as ptr bytes.free(ck); bytes.free(civ); bytes.free(sk); bytes.free(siv) bytes.free(cvd); bytes.free(cfin); bytes.free(cfin_rec) @@ -1917,6 +1819,7 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear conn.res_master = res_master2 conn.res_hash_algo = str_to_bytes_local(hash_algo) conn.res_hash_len = hash_len + conn.group = tls13_hs.GROUP_X25519 // psk_dhe_ke over the x25519 share result = conn as ptr bytes.free(ck); bytes.free(civ); bytes.free(sk); bytes.free(siv) bytes.free(cvd); bytes.free(cfin); bytes.free(cfin_rec) diff --git a/std/cryptography/tls13_server/module.ae b/std/cryptography/tls13_server/module.ae index 28e2b24e2..abca7734d 100644 --- a/std/cryptography/tls13_server/module.ae +++ b/std/cryptography/tls13_server/module.ae @@ -189,6 +189,20 @@ fn send_record_raw(sock: ptr, rec: ptr) -> string { return "" } +// The alert for a ClientHello parse_client_hello refused: illegal_parameter +// (47) for a malformed X25519MLKEM768 share (RFC 10024 §4.2), +// handshake_failure (40) when nothing offered is something we do, and +// decode_error (50) for the rest, which are all framing faults. +fn ch_error_alert(perr: string) -> int { + if string.contains(perr, "X25519MLKEM768") == 1 { return 47 } + if string.contains(perr, "no key_share") == 1 { return 40 } + if string.contains(perr, "no mutually supported") == 1 { return 40 } + if string.contains(perr, "supported_versions") == 1 { return 70 } // protocol_version + return 50 +} + +// A fatal alert in the clear, before any keys exist. Best effort: the +// handshake is failing either way. fn send_plaintext_alert(sock: ptr, level: int, desc: int) { b = bytes.new(7) bytes.set(b, 0, 0x15) // alert @@ -314,8 +328,8 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p has_sni: 0, saw_supported_versions: 0 } perr = tls13_hs.parse_client_hello(bytes.view(ch_msg)[0..ch_msg_len], &ch_parsed) if string.equals(perr, "") != 1 { - send_plaintext_alert(sock, 2, 47) // illegal_parameter - bytes.free(ch_msg); recio_free(rio) + send_plaintext_alert(sock, 2, ch_error_alert(perr)) + tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); recio_free(rio) return null, perr } @@ -376,7 +390,7 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p x_ss, xerr = x25519.agree(x25519_priv, ch_parsed.key_share) if string.equals(xerr, "") != 1 || x_ss == null { if x_ss != null { bytes.free(x_ss) } - send_plaintext_alert(sock, 2, 40) // handshake_failure + send_plaintext_alert(sock, 2, 47) // illegal_parameter (RFC 8446 §7.4.2) tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, xerr } diff --git a/std/http/README.md b/std/http/README.md index 329e995b9..479b33f04 100644 --- a/std/http/README.md +++ b/std/http/README.md @@ -74,16 +74,18 @@ import std.http import std.cryptography.tls13_server // pure TLS server; needs --with=fs ``` -The pure backend has constraints the OpenSSL one does not, and each fails as a +The pure backend has constraints the OpenSSL one does not, and most fail as a **dropped connection rather than a message**, so they are worth knowing before debugging one: - the server key must be **ECDSA P-256**: there is no RSA signing path, so an RSA certificate produces a server that fails at the signature step (`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1`) -- key exchange is **X25519 only** +- key exchange is **X25519MLKEM768** (the post-quantum hybrid, preferred + when the client offers it) **or X25519** - **HelloRetryRequest is not implemented**, so a client offering only other - groups is refused rather than renegotiated + groups (a P-256-only client, say) is refused with a `handshake_failure` + alert rather than renegotiated The client side is the mirror, and is now wired the same way (#1849): `std.http.client` does HTTPS through OpenSSL where it is compiled in, and @@ -95,7 +97,9 @@ import std.cryptography.tls13_client ``` to the program and `https://` works in a cross-built binary that links no TLS -library at all. `set_insecure` and `set_cafile` are honoured on the pure path +library at all. The pure client offers X25519MLKEM768 by default, alongside +X25519 and P-256 key shares, so it gets post-quantum key exchange from any +server that supports it (#2483). `set_insecure` and `set_cafile` are honoured on the pure path too, and an https request through a forward proxy works because the CONNECT tunnel is established before the handshake either way. diff --git a/tests/integration/https_pure_tls_vertical/bad_share.ae b/tests/integration/https_pure_tls_vertical/bad_share.ae new file mode 100644 index 000000000..4e3518da6 --- /dev/null +++ b/tests/integration/https_pure_tls_vertical/bad_share.ae @@ -0,0 +1,107 @@ +// The pure server's answer to a bad X25519MLKEM768 key share (#2483): a +// fatal illegal_parameter alert (RFC 10024 §4.2), in the clear, and no +// ServerHello. +// +// Two ClientHellos, one connection each: +// modulus a 1216-byte share whose ML-KEM half fails the FIPS 203 §7.2 +// modulus check (every 12-bit coefficient decodes to 4095 >= q); +// length a 500-byte share, the wrong size for the group. +// Either one reaching encapsulation, or answered with a ServerHello, fails. + +import std.cryptography.tls13_hs +import std.bytes +import std.string +import std.tcp +import std.os + +extern exit(code: int) + +fn filled(n: int, v: int) -> ptr { + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, v) + i = i + 1 + } + return b +} + +// Send a ClientHello offering only X25519MLKEM768 with `share`, and return +// the server's alert as "level:description" ("2:47" is a fatal +// illegal_parameter), or a description of whatever came instead. +fn probe(port: int, share: ptr, share_len: int) -> string { + sock, err = tcp.connect("127.0.0.1", port) + if err != "" { return "connect: ${err}" } + rnd = filled(32, 1) + sid = filled(32, 2) + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519MLKEM768, bytes.view(share)[0..share_len], 0, null, 0, null, "127.0.0.1") + ch_len = bytes.length(ch) + rec = bytes.new(5 + ch_len) + bytes.set(rec, 0, 0x16) // handshake + bytes.set(rec, 1, 0x03) + bytes.set(rec, 2, 0x01) + bytes.set(rec, 3, (ch_len >> 8) & 0xFF) + bytes.set(rec, 4, ch_len & 0xFF) + bytes.copy_from_bytes(rec, 5, ch, 0, ch_len) + _sent, werr = tcp.write_all(sock, bytes.view(rec)[0..5 + ch_len]) + bytes.free(rec); bytes.free(ch); bytes.free(rnd); bytes.free(sid) + if werr != "" { + tcp.close(sock) + return "write: ${werr}" + } + + got = bytes.new(7) + bytes.set(got, 6, 0) + n = 0 + tries = 0 + while n < 7 && tries < 50 { + r, rerr = tcp.read_into(sock, bytes.view(got)[n..7]) + if rerr == "timeout" { + tries = tries + 1 + } else if rerr != "" { + tries = 50 + } else { + n = n + r + } + } + tcp.close(sock) + if n < 7 { + bytes.free(got) + return "only ${n} bytes before the connection ended" + } + out = "" + if bytes.get(got, 0) == 0x15 { + out = "${bytes.get(got, 5)}:${bytes.get(got, 6)}" + } else { + out = "record type ${bytes.get(got, 0)}" + } + bytes.free(got) + return out +} + +main() { + port, _perr = string.to_int(os_getenv("AE_TEST_PORT")) + fails = 0 + + bad_ek = filled(1216, 0xFF) + r1 = probe(port, bad_ek, 1216) + bytes.free(bad_ek) + if r1 == "2:47" { + println("PASS: modulus: fatal illegal_parameter") + } else { + println("FAIL: modulus: want alert 2:47, got ${r1}") + fails = fails + 1 + } + + short = filled(500, 3) + r2 = probe(port, short, 500) + bytes.free(short) + if r2 == "2:47" { + println("PASS: length: fatal illegal_parameter") + } else { + println("FAIL: length: want alert 2:47, got ${r2}") + fails = fails + 1 + } + + if fails != 0 { exit(1) } +} diff --git a/tests/integration/https_pure_tls_vertical/client.ae b/tests/integration/https_pure_tls_vertical/client.ae index 3e6c3f2f8..c5755e120 100644 --- a/tests/integration/https_pure_tls_vertical/client.ae +++ b/tests/integration/https_pure_tls_vertical/client.ae @@ -37,6 +37,9 @@ main() { exit(1) } println("PASS: pure-Aether TLS 1.3 handshake (no OpenSSL on this side)") + // The key exchange group the server chose: 4588 is X25519MLKEM768 + // (0x11EC), 29 x25519, 23 secp256r1. The runner checks it per server. + println("GROUP: ${tls13_client.conn_group(conn)}") // Frame one HTTP/1.1 request over the TLS stream. req = "GET / HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n" diff --git a/tests/integration/https_pure_tls_vertical/test_https_pure_tls_vertical.sh b/tests/integration/https_pure_tls_vertical/test_https_pure_tls_vertical.sh index dce7f577e..5c6f89417 100755 --- a/tests/integration/https_pure_tls_vertical/test_https_pure_tls_vertical.sh +++ b/tests/integration/https_pure_tls_vertical/test_https_pure_tls_vertical.sh @@ -68,6 +68,8 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "$TMP || { sed -n '1,12p' "$TMP/b1.log"; fail "server did not build"; } "$AE" build "$SCRIPT_DIR/client.ae" -o "$TMP/cli" >"$TMP/b2.log" 2>&1 \ || { sed -n '1,12p' "$TMP/b2.log"; fail "pure-TLS client did not build"; } +"$AE" build "$SCRIPT_DIR/bad_share.ae" -o "$TMP/bad" >"$TMP/b3.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b3.log"; fail "bad-share probe did not build"; } CERT_PATH="$TMP/cert.pem" KEY_PATH="$TMP/key.pem" "$TMP/srv" >"$TMP/srv.log" 2>&1 & SRV_PID=$! @@ -131,6 +133,22 @@ case "$OUT3" in *"PASS: HTTP/1.1 response received over pure TLS"*) ;; *) fail "Permutation 3: no HTTP response over TLS stream" ;; esac +# Both ends are ours, so they must settle on X25519MLKEM768 (#2483). The +# OpenSSL server in permutation 1 is left unchecked: which group it picks +# depends on the OpenSSL version (3.5 and later prefer the hybrid too). +case "$OUT3" in + *"GROUP: 4588"*) ;; + *) printf '%s\n' "$OUT3" | sed 's/^/ cli: /' + fail "Permutation 3: pure client and pure server did not negotiate X25519MLKEM768" ;; +esac + +# --- Permutation 3b: a bad X25519MLKEM768 share -> Pure-Aether TLS Server --- +# RFC 10024 §4.2: a share failing the FIPS 203 §7.2 check, or of the wrong +# length, gets a fatal illegal_parameter alert. The server keeps serving. +OUT3B=$(AE_TEST_PORT="$PORT" "$TMP/bad" 2>&1) || { + printf '%s\n' "$OUT3B" | grep -vE 'warning: unresolved|-->' | sed 's/^/ bad: /' + fail "Permutation 3b: the pure server mishandled a bad X25519MLKEM768 share" +} # --- Permutation 4: curl -> Pure-Aether TLS Server --- CURL_OUT4=$(curl -s --cacert "$TMP/cert.pem" https://127.0.0.1:$PORT/ 2>&1) || fail "Permutation 4 (curl -> Pure server) failed" @@ -139,4 +157,4 @@ case "$CURL_OUT4" in *) fail "Permutation 4: unexpected response '$CURL_OUT4'" ;; esac -echo " [PASS] https_pure_tls_vertical: all 4 client/server permutations succeeded" +echo " [PASS] https_pure_tls_vertical: all client/server permutations succeeded"