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..5b5bf6724 --- /dev/null +++ b/new_changelogs/20261024T214017Z-changed-tls13-x25519mlkem768-default.md @@ -0,0 +1,15 @@ +- **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 1154bc8f8..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). @@ -720,36 +693,48 @@ 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. + + // 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 != "" { + tcp.close(sock) + return null, kerr + } p256_priv = rand_into(32) 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) - pub = x_pub // primary keyshare (x25519); cleanup frees this - _pub_len = x_pub_len + + // 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) - 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 + 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) // 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(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(dk); bytes.free(p256_priv) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, sherr } @@ -759,97 +744,43 @@ 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(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); 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 { - 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(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); 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 - cur_priv = rp - own_priv = 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_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 { - cleanup_hello(pub2, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); 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 { - cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - 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) - perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) - if string.equals(perr, "") != 1 { - 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) - return null, perr - } - if parsed.is_hello_retry == 1 { - 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) - 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 - } else { - cur_group = tls13_hs.GROUP_X25519 - cur_priv = x25519_priv - } + // The shared secret for the group the server chose. + shared = null + shared_len = 32 + agerr = "" + if parsed.group == tls13_hs.GROUP_X25519MLKEM768 { + 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 { + agerr = "server chose a group we did not offer" } - - shared, agerr = ecdhe_shared(cur_group, cur_priv, parsed.key_share, parsed.key_share_len) - if string.equals(agerr, "") != 1 { + 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); if own_priv == 1 { bytes.free(cur_priv) } - transcript_free(tr); recio_free(rio); tcp.close(sock) + 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. @@ -861,139 +792,70 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli 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 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) 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) + } else { + rc = result as *ClientConn + rc.group = parsed.group } 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). -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) - } - } +// 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 +} - 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 +// 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) + conn, err = connect(host, port, priv) + bytes.free(priv) + return conn, err } // The shared post-ServerHello handshake tail: derive handshake keys from the @@ -1159,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) @@ -1956,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_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..abca7734d 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,33 @@ 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 + 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,34 +328,97 @@ 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 { - 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 } 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, 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 + } + 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 +427,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 +509,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 +532,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/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/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) 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"