diff --git a/vertx-core/src/main/asciidoc/http.adoc b/vertx-core/src/main/asciidoc/http.adoc index a4c289abf8a..dd86d800ce7 100644 --- a/vertx-core/src/main/asciidoc/http.adoc +++ b/vertx-core/src/main/asciidoc/http.adoc @@ -82,14 +82,46 @@ To handle `h2` requests, TLS must be enabled: ---- The rise of quantum computers will make key exchange protocols such as x25519 obsolete as they will be able to "crack" secret keys quickly. -Vert.x proposes a quantum-safe key exchange protocol, x25519MLKEM768 (official recommendation of NIST) to ensure sessions over TLS are safe against quantum computers. +Vert.x supports post-quantum cryptography (PQC) key exchange via X25519MLKEM768 (official recommendation of NIST) to ensure sessions over TLS are safe against quantum computers. -Hybrid key exchange must be enabled with {@link io.vertx.core.net.SSLOptions#setUseHybridKeyExchangeProtocol(boolean)} and **requires OpenSSL** — it does not work with the JDK SSL engine. You must configure {@link io.vertx.core.net.OpenSSLEngineOptions} and add the following dependencies: +PQC is configured with two properties: + +- {@link io.vertx.core.net.SSLOptions#setKeyExchangeGroups} — a list of key exchange group names (e.g. `["X25519MLKEM768", "X25519"]`) +- {@link io.vertx.core.net.SSLOptions#setPqcEnforcementPolicy} — the enforcement policy: `RELAXED` (default), `CLIENT_NEGOTIATED`, or `STRICT` + +The three enforcement modes behave as follows: + +- **RELAXED**: uses the specified key exchange groups as-is; if none are specified, the SSL engine negotiates normally. No PQC enforcement. +- **CLIENT_NEGOTIATED**: the server requires PQC support (X25519MLKEM768 is prepended to the groups if not present), but tolerates clients that do not support PQC. +- **STRICT**: both server and client must support PQC. The key exchange groups are replaced with only X25519MLKEM768. Non-PQC clients will fail the TLS handshake. + +For both `STRICT` and `CLIENT_NEGOTIATED`, the application fails with either "PQC enforcement policy requires X25519MLKEM768 but the configured SSL engine does not support it" or "PQC enforcement policy requires X25519MLKEM768 but neither JDK nor OpenSSL support it" if pqc is not supported by the SSL engine. + +For `STRICT` and `CLIENT_NEGOTIATED` modes, you need an SslEngine supporting X25519MLKEM768. `JdkSslEngine` supports it starting with JDK 27 (preview feature), and OpenSsl supports it starting with OpenSsl 3.5. If no `SslEngine` is specified by the user, the first engine supporting pqc will be selected, starting with `JdkSslEngine` + +Using OpenSsl >= 3.5 requires the following dependencies: - `io.netty:netty-tcnative-classes` (version managed by the Netty BOM) - An OpenSSL provider such as `io.smallrye:smallrye-openssl` -If OpenSSL is not available at runtime, the TLS handshake will fail rather than silently falling back to a non-quantum-safe key exchange. +**PQC enforcement policy summary:** +[cols="1,1,1,2", options="header"] +|=== +| pqc-enforcement-policy | server-pqc-supports | client-pqc-supports | Result +.3+| strict +| false | required / true / false | **app fails** with error "PQC enforcement policy STRICT requires X25519MLKEM768 but the configured SSL engine does not support it" +| true | true / required | app starts and ssl handshake succeeds +| true | false | app starts but **ssl handshake fails** + +.2+| client_negotiated +| false | required / true / false | **app fails** with error "PQC enforcement policy STRICT requires X25519MLKEM768 but the configured SSL engine does not support it" +| true | required / true / false | app starts and ssl handshake succeeds + +.3+| relaxed +| true | required / true / false | app starts and ssl handshake succeeds +| false | true / false | app starts and ssl handshake succeeds +| false | required | app starts but **ssl handshake fails** +|=== With plain text (TLS is disabled), the server handles `h2c` requests that wants to upgrade connections presenting an HTTP/1.1 upgrade request to HTTP/2. It also accepts direct `h2c` (with prior knowledge) connections beginning with diff --git a/vertx-core/src/main/generated/io/vertx/core/net/SSLOptionsConverter.java b/vertx-core/src/main/generated/io/vertx/core/net/SSLOptionsConverter.java index 827f7ffd93f..73c6a38cde3 100644 --- a/vertx-core/src/main/generated/io/vertx/core/net/SSLOptionsConverter.java +++ b/vertx-core/src/main/generated/io/vertx/core/net/SSLOptionsConverter.java @@ -41,9 +41,19 @@ static void fromJson(Iterable> json, SSLOpti obj.setUseAlpn((Boolean)member.getValue()); } break; - case "useHybridKeyExchangeProtocol": - if (member.getValue() instanceof Boolean) { - obj.setUseHybridKeyExchangeProtocol((Boolean)member.getValue()); + case "keyExchangeGroups": + if (member.getValue() instanceof JsonArray) { + java.util.ArrayList list = new java.util.ArrayList<>(); + ((Iterable)member.getValue()).forEach( item -> { + if (item instanceof String) + list.add((String)item); + }); + obj.setKeyExchangeGroups(list); + } + break; + case "pqcEnforcementPolicy": + if (member.getValue() instanceof String) { + obj.setPqcEnforcementPolicy(io.vertx.core.net.PqcEnforcementPolicy.valueOf((String)member.getValue())); } break; case "enabledSecureTransportProtocols": @@ -101,7 +111,14 @@ static void toJson(SSLOptions obj, java.util.Map json) { json.put("crlValues", array); } json.put("useAlpn", obj.isUseAlpn()); - json.put("useHybridKeyExchangeProtocol", obj.isUseHybridKeyExchangeProtocol()); + if (obj.getKeyExchangeGroups() != null) { + JsonArray array = new JsonArray(); + obj.getKeyExchangeGroups().forEach(item -> array.add(item)); + json.put("keyExchangeGroups", array); + } + if (obj.getPqcEnforcementPolicy() != null) { + json.put("pqcEnforcementPolicy", obj.getPqcEnforcementPolicy().name()); + } if (obj.getEnabledSecureTransportProtocols() != null) { JsonArray array = new JsonArray(); obj.getEnabledSecureTransportProtocols().forEach(item -> array.add(item)); diff --git a/vertx-core/src/main/java/io/vertx/core/internal/net/SslChannelProvider.java b/vertx-core/src/main/java/io/vertx/core/internal/net/SslChannelProvider.java index 97ed8e3723e..2b2906b34a0 100644 --- a/vertx-core/src/main/java/io/vertx/core/internal/net/SslChannelProvider.java +++ b/vertx-core/src/main/java/io/vertx/core/internal/net/SslChannelProvider.java @@ -41,22 +41,22 @@ public class SslChannelProvider { private final Executor workerPool; private final boolean sni; - private final boolean useHybridKeyExchangeProtocol; + private final List keyExchangeGroups; private final SslContextProvider sslContextProvider; public SslChannelProvider(VertxInternal vertx, SslContextProvider sslContextProvider, boolean sni) { - this(vertx, sslContextProvider, sni, false); + this(vertx, sslContextProvider, sni, null); } public SslChannelProvider(VertxInternal vertx, SslContextProvider sslContextProvider, boolean sni, - boolean useHybridKeyExchangeProtocol) { + List keyExchangeGroups) { this.workerPool = vertx.internalWorkerPool().executor(); this.sni = sni; - this.useHybridKeyExchangeProtocol = useHybridKeyExchangeProtocol; + this.keyExchangeGroups = keyExchangeGroups; this.sslContextProvider = sslContextProvider; } @@ -77,8 +77,8 @@ public SslHandler createClientSslHandler(HostAndPort peer, } else { sslHandler = sslContext.newHandler(ByteBufAllocator.DEFAULT, delegatedTaskExec); } - if (useHybridKeyExchangeProtocol) { - applyHybridCurves(sslHandler); + if (keyExchangeGroups != null && !keyExchangeGroups.isEmpty()) { + applyKeyExchangeGroups(sslHandler, keyExchangeGroups); } sslHandler.setHandshakeTimeout(sslHandshakeTimeout, sslHandshakeTimeoutUnit); return sslHandler; @@ -101,8 +101,8 @@ private SslHandler createServerSslHandler(List applicationProtocols, lon } else { sslHandler = sslContext.newHandler(ByteBufAllocator.DEFAULT, delegatedTaskExec); } - if (useHybridKeyExchangeProtocol) { - applyHybridCurves(sslHandler); + if (keyExchangeGroups != null && !keyExchangeGroups.isEmpty()) { + applyKeyExchangeGroups(sslHandler, keyExchangeGroups); } sslHandler.setHandshakeTimeout(sslHandshakeTimeout, sslHandshakeTimeoutUnit); return sslHandler; @@ -111,19 +111,20 @@ private SslHandler createServerSslHandler(List applicationProtocols, lon private SniHandler createSniHandler(List applicationProtocols, long sslHandshakeTimeout, TimeUnit sslHandshakeTimeoutUnit, HostAndPort remoteAddress) { Executor delegatedTaskExec = sslContextProvider.useWorkerPool() ? workerPool : ImmediateExecutor.INSTANCE; return new VertxSniHandler(((ServerSslContextProvider)sslContextProvider).serverNameAsyncMapping(delegatedTaskExec, applicationProtocols), sslHandshakeTimeoutUnit.toMillis(sslHandshakeTimeout), delegatedTaskExec, - useHybridKeyExchangeProtocol, remoteAddress); + keyExchangeGroups, remoteAddress); } - static void applyHybridCurves(SslHandler sslHandler) { + static void applyKeyExchangeGroups(SslHandler sslHandler, List groups) { + String curvesList = String.join(":", groups); try { long sslPtr = ((ReferenceCountedOpenSslEngine) sslHandler.engine()).sslPointer(); - boolean success = SSL.setCurvesList(sslPtr, "X25519MLKEM768"); + boolean success = SSL.setCurvesList(sslPtr, curvesList); if (!success) { - log.error("Failed to set hybrid PQC groups on SSL instance, closing engine to prevent non-PQC fallback"); + log.error("Failed to set key exchange groups [" + curvesList + "] on SSL instance, closing engine"); sslHandler.engine().closeOutbound(); } } catch (Exception e) { - log.error("Unable to apply hybrid PQC curves: " + e.getMessage() + ", closing engine to prevent non-PQC fallback"); + log.error("Unable to apply key exchange groups: " + e.getMessage() + ", closing engine"); sslHandler.engine().closeOutbound(); } } diff --git a/vertx-core/src/main/java/io/vertx/core/internal/net/VertxSniHandler.java b/vertx-core/src/main/java/io/vertx/core/internal/net/VertxSniHandler.java index 43209ae382c..386714c7235 100644 --- a/vertx-core/src/main/java/io/vertx/core/internal/net/VertxSniHandler.java +++ b/vertx-core/src/main/java/io/vertx/core/internal/net/VertxSniHandler.java @@ -17,6 +17,7 @@ import io.netty.util.AsyncMapping; import io.vertx.core.net.HostAndPort; +import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; @@ -28,15 +29,15 @@ class VertxSniHandler extends SniHandler { private final Executor delegatedTaskExec; - private final boolean useHybridKeyExchangeProtocol; + private final List keyExchangeGroups; private final HostAndPort remoteAddress; public VertxSniHandler(AsyncMapping mapping, long handshakeTimeoutMillis, Executor delegatedTaskExec, - boolean useHybridKeyExchangeProtocol, HostAndPort remoteAddress) { + List keyExchangeGroups, HostAndPort remoteAddress) { super(mapping, handshakeTimeoutMillis); this.delegatedTaskExec = delegatedTaskExec; - this.useHybridKeyExchangeProtocol = useHybridKeyExchangeProtocol; + this.keyExchangeGroups = keyExchangeGroups; this.remoteAddress = remoteAddress; } @@ -48,8 +49,8 @@ protected SslHandler newSslHandler(SslContext context, ByteBufAllocator allocato } else { sslHandler = context.newHandler(allocator, delegatedTaskExec); } - if (useHybridKeyExchangeProtocol) { - SslChannelProvider.applyHybridCurves(sslHandler); + if (keyExchangeGroups != null && !keyExchangeGroups.isEmpty()) { + SslChannelProvider.applyKeyExchangeGroups(sslHandler, keyExchangeGroups); } sslHandler.setHandshakeTimeout(handshakeTimeoutMillis, TimeUnit.MILLISECONDS); return sslHandler; diff --git a/vertx-core/src/main/java/io/vertx/core/internal/tls/SslContextManager.java b/vertx-core/src/main/java/io/vertx/core/internal/tls/SslContextManager.java index 830f8e36b16..134bf64b1d2 100755 --- a/vertx-core/src/main/java/io/vertx/core/internal/tls/SslContextManager.java +++ b/vertx-core/src/main/java/io/vertx/core/internal/tls/SslContextManager.java @@ -19,6 +19,8 @@ import io.vertx.core.http.ClientAuth; import io.vertx.core.impl.utils.LruCache; import io.vertx.core.internal.ContextInternal; +import io.vertx.core.internal.logging.Logger; +import io.vertx.core.internal.logging.LoggerFactory; import io.vertx.core.net.*; import io.vertx.core.spi.tls.SslContextFactory; @@ -37,6 +39,8 @@ */ public abstract class SslContextManager

{ + private static final Logger log = LoggerFactory.getLogger(SslContextManager.class); + public static io.netty.handler.ssl.ClientAuth mapClientAuth(ClientAuth auth) { return CLIENT_AUTH_MAPPING.get(auth); } @@ -73,6 +77,38 @@ public SslContextManager(SSLEngineOptions sslEngineOptions, int cacheMaxSize) { * Resolve the ssl engine options to use for properly running the configured options. */ public static SSLEngineOptions resolveEngineOptions(SSLEngineOptions engineOptions, boolean useAlpn) { + return resolveEngineOptions(engineOptions, useAlpn, PqcEnforcementPolicy.RELAXED); + } + + /** + * Resolve the ssl engine options to use for properly running the configured options, + * taking PQC enforcement policy into account. + */ + public static SSLEngineOptions resolveEngineOptions(SSLEngineOptions engineOptions, boolean useAlpn, PqcEnforcementPolicy pqcPolicy) { + if (pqcPolicy == null) { + pqcPolicy = PqcEnforcementPolicy.RELAXED; + } + if (pqcPolicy == PqcEnforcementPolicy.STRICT || pqcPolicy == PqcEnforcementPolicy.CLIENT_NEGOTIATED) { + if (engineOptions != null) { + boolean pqcSupported; + if (engineOptions instanceof JdkSSLEngineOptions) { + pqcSupported = JdkSSLEngineOptions.isPqcAvailable(); + } else { + pqcSupported = OpenSSLEngineOptions.isPqcAvailable(); + } + if (!pqcSupported) { + throw new VertxException("PQC enforcement policy " + pqcPolicy + " requires X25519MLKEM768 but the configured SSL engine does not support it"); + } + } else { + if (JdkSSLEngineOptions.isPqcAvailable()) { + engineOptions = new JdkSSLEngineOptions(); + } else if (OpenSSLEngineOptions.isPqcAvailable()) { + engineOptions = new OpenSSLEngineOptions(); + } else { + throw new VertxException("PQC enforcement policy " + pqcPolicy + " requires X25519MLKEM768 but neither JDK nor OpenSSL support it"); + } + } + } if (engineOptions == null) { if (useAlpn) { if (JdkSSLEngineOptions.isAlpnAvailable()) { @@ -110,6 +146,48 @@ public static SSLEngineOptions resolveEngineOptions(SSLEngineOptions engineOptio return engineOptions; } + private static final String X25519MLKEM768 = "X25519MLKEM768"; + + // If the user didn't specify any group we return a default list, with X25519MLKEM768 prepended. + // This list is the default when weak named curves are removed (see https://www.java.com/en/configure_crypto.html) + private static final List DEFAULT_KEY_EXCHANGE_GROUPS = List.of(X25519MLKEM768, "X25519", "secp256r1", "x448", + "secp384r1", "secp521r1"); + + /** + * Resolve the effective key exchange groups based on the PQC enforcement policy. + * Called once at startup to avoid per-connection computation and logging. + */ + public static List resolveKeyExchangeGroups(List groups, PqcEnforcementPolicy pqcPolicy) { + if (pqcPolicy == null) { + pqcPolicy = PqcEnforcementPolicy.RELAXED; + } + switch (pqcPolicy) { + case STRICT: + if (groups != null && !groups.isEmpty()) { + if (!(groups.size() == 1 && groups.contains(X25519MLKEM768))) { + log.warn("PQC enforcement policy is STRICT: overriding key exchange groups " + groups + " with [" + X25519MLKEM768 + "]"); + } + } + return List.of(X25519MLKEM768); + case CLIENT_NEGOTIATED: + if (groups == null || groups.isEmpty()) { + log.warn("No key exchange groups list was specified, a default list containing X25519MLKEM768 is selected"); + return DEFAULT_KEY_EXCHANGE_GROUPS; + } + if (!groups.contains(X25519MLKEM768)) { + log.warn("PQC enforcement policy is CLIENT_NEGOTIATED: prepending " + X25519MLKEM768 + " to key exchange groups " + groups); + List result = new ArrayList<>(groups.size() + 1); + result.add(X25519MLKEM768); + result.addAll(groups); + return result; + } + return groups; + case RELAXED: + default: + return groups; + } + } + public synchronized int sniEntrySize() { int size = 0; for (Future

fut : sslContextProviderMap.values()) { diff --git a/vertx-core/src/main/java/io/vertx/core/net/ClientSSLOptions.java b/vertx-core/src/main/java/io/vertx/core/net/ClientSSLOptions.java index 6eb8cf57696..fd92ebb5354 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/ClientSSLOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/ClientSSLOptions.java @@ -131,8 +131,18 @@ public ClientSSLOptions setUseAlpn(boolean useAlpn) { } @Override - public ClientSSLOptions setUseHybridKeyExchangeProtocol(boolean useHybridKeyExchangeProtocol) { - return (ClientSSLOptions) super.setUseHybridKeyExchangeProtocol(useHybridKeyExchangeProtocol); + public ClientSSLOptions setKeyExchangeGroups(List keyExchangeGroups) { + return (ClientSSLOptions) super.setKeyExchangeGroups(keyExchangeGroups); + } + + @Override + public ClientSSLOptions addKeyExchangeGroup(String group) { + return (ClientSSLOptions) super.addKeyExchangeGroup(group); + } + + @Override + public ClientSSLOptions setPqcEnforcementPolicy(PqcEnforcementPolicy pqcEnforcementPolicy) { + return (ClientSSLOptions) super.setPqcEnforcementPolicy(pqcEnforcementPolicy); } @Override diff --git a/vertx-core/src/main/java/io/vertx/core/net/JdkSSLEngineOptions.java b/vertx-core/src/main/java/io/vertx/core/net/JdkSSLEngineOptions.java index 6c2f7469b53..468b1b5f496 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/JdkSSLEngineOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/JdkSSLEngineOptions.java @@ -30,6 +30,7 @@ public class JdkSSLEngineOptions extends SSLEngineOptions { private static Boolean jdkAlpnAvailable; + private static Boolean jdkPqcAvailable; /** * @return if alpn support is available via the JDK SSL engine @@ -57,6 +58,24 @@ public static synchronized boolean isAlpnAvailable() { return jdkAlpnAvailable; } + /** + * @return if PQC key exchange (X25519MLKEM768) is available via the JDK SSL engine + */ + public static synchronized boolean isPqcAvailable() { + if (jdkPqcAvailable == null) { + boolean available = false; + try { + Class kemClass = Class.forName("javax.crypto.KEM"); + java.lang.reflect.Method getInstance = kemClass.getDeclaredMethod("getInstance", String.class); + getInstance.invoke(null, "ML-KEM-768"); + available = true; + } catch (Exception ignore) { + } + jdkPqcAvailable = available; + } + return jdkPqcAvailable; + } + public JdkSSLEngineOptions() { } diff --git a/vertx-core/src/main/java/io/vertx/core/net/OpenSSLEngineOptions.java b/vertx-core/src/main/java/io/vertx/core/net/OpenSSLEngineOptions.java index df02983cc95..c04d07fa947 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/OpenSSLEngineOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/OpenSSLEngineOptions.java @@ -11,8 +11,15 @@ package io.vertx.core.net; +import io.netty.buffer.ByteBufAllocator; import io.netty.handler.ssl.OpenSsl; +import io.netty.handler.ssl.ReferenceCountedOpenSslEngine; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.internal.tcnative.SSL; import io.vertx.codegen.annotations.DataObject; import io.vertx.codegen.json.annotations.JsonGen; import io.vertx.core.json.JsonObject; @@ -28,6 +35,8 @@ @JsonGen(publicConverter = false, inheritConverter = true) public class OpenSSLEngineOptions extends SSLEngineOptions { + private static Boolean opensslPqcAvailable; + /** * @return when OpenSSL is available */ @@ -42,6 +51,33 @@ public static boolean isAlpnAvailable() { return SslProvider.isAlpnSupported(SslProvider.OPENSSL); } + /** + * @return if PQC key exchange (X25519MLKEM768) is available via the OpenSSL engine + */ + public static synchronized boolean isPqcAvailable() { + if (opensslPqcAvailable == null) { + boolean available = false; + if (OpenSsl.isAvailable()) { + try { + SslContext ctx = SslContextBuilder.forClient() + .sslProvider(SslProvider.OPENSSL) + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .build(); + SslHandler handler = ctx.newHandler(ByteBufAllocator.DEFAULT); + try { + long sslPtr = ((ReferenceCountedOpenSslEngine) handler.engine()).sslPointer(); + available = SSL.setCurvesList(sslPtr, "X25519MLKEM768"); + } finally { + handler.engine().closeOutbound(); + } + } catch (Exception ignore) { + } + } + opensslPqcAvailable = available; + } + return opensslPqcAvailable; + } + /** * Default value of whether session cache is enabled in open SSL session server context = true */ diff --git a/vertx-core/src/main/java/io/vertx/core/net/PqcEnforcementPolicy.java b/vertx-core/src/main/java/io/vertx/core/net/PqcEnforcementPolicy.java new file mode 100644 index 00000000000..8b7f5f68b80 --- /dev/null +++ b/vertx-core/src/main/java/io/vertx/core/net/PqcEnforcementPolicy.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2011-2024 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ + +package io.vertx.core.net; + +import io.vertx.codegen.annotations.VertxGen; + +/** + * Policy for enforcing post-quantum cryptography (PQC) key exchange. + */ +@VertxGen +public enum PqcEnforcementPolicy { + /** + * No PQC enforcement. Key exchange groups are used as-is if specified. + * If no groups are specified, the SSL engine negotiates normally. + */ + RELAXED, + /** + * PQC is enforced on the server side but clients that do not support PQC are tolerated. + * X25519MLKEM768 is prepended to the key exchange groups if not already present. + * If PQC is not available at runtime, the application fails to start with a VertxException: + * "PQC enforcement policy CLIENT_NEGOTIATED requires X25519MLKEM768 but the configured SSL engine does not support it" + */ + CLIENT_NEGOTIATED, + /** + * PQC is strictly enforced. Both server and client must support PQC. + * The key exchange groups are replaced with only X25519MLKEM768. + * If PQC is not available at runtime, the application fails to start with a VertxException: + * "PQC enforcement policy STRICT requires X25519MLKEM768 but the configured SSL engine does not support it" + * Non-PQC clients will fail the TLS handshake. + */ + STRICT +} diff --git a/vertx-core/src/main/java/io/vertx/core/net/SSLOptions.java b/vertx-core/src/main/java/io/vertx/core/net/SSLOptions.java index ade0c5865a4..21ec8aedadd 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/SSLOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/SSLOptions.java @@ -40,9 +40,9 @@ public class SSLOptions { public static final boolean DEFAULT_USE_ALPN = false; /** - * Default use hybrid = false + * Default PQC enforcement policy = RELAXED */ - public static final boolean DEFAULT_USE_HYBRID = false; + public static final PqcEnforcementPolicy DEFAULT_PQC_ENFORCEMENT_POLICY = PqcEnforcementPolicy.RELAXED; /** * The default value of SSL handshake timeout = 10 @@ -71,7 +71,8 @@ public class SSLOptions { List crlPaths; List crlValues; private boolean useAlpn; - private boolean useHybridKeyExchangeProtocol; + private List keyExchangeGroups; + private PqcEnforcementPolicy pqcEnforcementPolicy; private Set enabledSecureTransportProtocols; private List applicationLayerProtocols; @@ -96,7 +97,8 @@ public SSLOptions(SSLOptions other) { this.crlPaths = new ArrayList<>(other.getCrlPaths()); this.crlValues = new ArrayList<>(other.getCrlValues()); this.useAlpn = other.useAlpn; - this.useHybridKeyExchangeProtocol = other.useHybridKeyExchangeProtocol; + this.keyExchangeGroups = other.keyExchangeGroups != null ? new ArrayList<>(other.keyExchangeGroups) : null; + this.pqcEnforcementPolicy = other.pqcEnforcementPolicy; this.enabledSecureTransportProtocols = other.getEnabledSecureTransportProtocols() == null ? new LinkedHashSet<>() : new LinkedHashSet<>(other.getEnabledSecureTransportProtocols()); this.applicationLayerProtocols = other.getApplicationLayerProtocols() != null ? new ArrayList<>(other.getApplicationLayerProtocols()) : null; } @@ -119,7 +121,8 @@ protected void init() { crlPaths = new ArrayList<>(); crlValues = new ArrayList<>(); useAlpn = DEFAULT_USE_ALPN; - useHybridKeyExchangeProtocol = DEFAULT_USE_HYBRID; + keyExchangeGroups = null; + pqcEnforcementPolicy = DEFAULT_PQC_ENFORCEMENT_POLICY; enabledSecureTransportProtocols = new LinkedHashSet<>(DEFAULT_ENABLED_SECURE_TRANSPORT_PROTOCOLS); applicationLayerProtocols = null; } @@ -262,32 +265,63 @@ public SSLOptions setUseAlpn(boolean useAlpn) { } /** - * @return whether the hybrid key exchange protocol X25519MLKEM768 is enabled + * @return the list of key exchange group names, or {@code null} if not set */ - public boolean isUseHybridKeyExchangeProtocol() { - return useHybridKeyExchangeProtocol; + public List getKeyExchangeGroups() { + return keyExchangeGroups; } /** - * Enable or disable the hybrid post-quantum key exchange protocol X25519MLKEM768. + * Set the list of key exchange group names to use for TLS connections. *

- * When enabled, TLS connections will use X25519MLKEM768 for key exchange, providing - * protection against quantum computer attacks. - *

- * This feature requires OpenSSL and will not work with the JDK SSL engine. You must: + * The effective groups used during the TLS handshake depend on the {@link #getPqcEnforcementPolicy()}: *

    - *
  • Use {@link OpenSSLEngineOptions} as the SSL engine
  • - *
  • Have {@code io.netty:netty-tcnative-classes} on the classpath
  • - *
  • Have an OpenSSL provider (e.g. {@code io.smallrye:smallrye-openssl}) on the classpath
  • + *
  • {@link PqcEnforcementPolicy#RELAXED}: uses the specified groups as-is, or engine defaults if {@code null}
  • + *
  • {@link PqcEnforcementPolicy#CLIENT_NEGOTIATED}: prepends {@code X25519MLKEM768} if not already present
  • + *
  • {@link PqcEnforcementPolicy#STRICT}: replaces the list with only {@code X25519MLKEM768}
  • *
- * If OpenSSL is not available, the TLS handshake will fail rather than silently falling back - * to a non-quantum-safe key exchange. * - * @param useHybridKeyExchangeProtocol {@code true} to enable hybrid key exchange + * @param keyExchangeGroups the key exchange group names + * @return a reference to this, so the API can be used fluently + */ + public SSLOptions setKeyExchangeGroups(List keyExchangeGroups) { + this.keyExchangeGroups = keyExchangeGroups; + return this; + } + + /** + * Add a key exchange group name. + * + * @param group the group name to add + * @return a reference to this, so the API can be used fluently + */ + public SSLOptions addKeyExchangeGroup(String group) { + Objects.requireNonNull(group, "group"); + if (keyExchangeGroups == null) { + keyExchangeGroups = new ArrayList<>(); + } + keyExchangeGroups.add(group); + return this; + } + + /** + * @return the PQC enforcement policy + */ + public PqcEnforcementPolicy getPqcEnforcementPolicy() { + return pqcEnforcementPolicy; + } + + /** + * Set the post-quantum cryptography enforcement policy. + *

+ * When set to {@link PqcEnforcementPolicy#STRICT} or {@link PqcEnforcementPolicy#CLIENT_NEGOTIATED}, + * the SSL engine will be automatically switched to OpenSSL if not already configured. + * + * @param pqcEnforcementPolicy the enforcement policy * @return a reference to this, so the API can be used fluently */ - public SSLOptions setUseHybridKeyExchangeProtocol(boolean useHybridKeyExchangeProtocol) { - this.useHybridKeyExchangeProtocol = useHybridKeyExchangeProtocol; + public SSLOptions setPqcEnforcementPolicy(PqcEnforcementPolicy pqcEnforcementPolicy) { + this.pqcEnforcementPolicy = Objects.requireNonNull(pqcEnforcementPolicy, "pqcEnforcementPolicy"); return this; } @@ -403,7 +437,8 @@ public boolean equals(Object obj) { Objects.equals(crlPaths, that.crlPaths) && Objects.equals(crlValues, that.crlValues) && useAlpn == that.useAlpn && - useHybridKeyExchangeProtocol == that.useHybridKeyExchangeProtocol && + Objects.equals(keyExchangeGroups, that.keyExchangeGroups) && + pqcEnforcementPolicy == that.pqcEnforcementPolicy && Objects.equals(enabledSecureTransportProtocols, that.enabledSecureTransportProtocols); } return false; @@ -411,7 +446,7 @@ public boolean equals(Object obj) { @Override public int hashCode() { - return Objects.hash(sslHandshakeTimeoutUnit.toNanos(sslHandshakeTimeout), keyCertOptions, trustOptions, enabledCipherSuites, crlPaths, crlValues, useAlpn, useHybridKeyExchangeProtocol, enabledSecureTransportProtocols); + return Objects.hash(sslHandshakeTimeoutUnit.toNanos(sslHandshakeTimeout), keyCertOptions, trustOptions, enabledCipherSuites, crlPaths, crlValues, useAlpn, keyExchangeGroups, pqcEnforcementPolicy, enabledSecureTransportProtocols); } /** diff --git a/vertx-core/src/main/java/io/vertx/core/net/ServerSSLOptions.java b/vertx-core/src/main/java/io/vertx/core/net/ServerSSLOptions.java index 53e7d3204b0..3e49aa308ff 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/ServerSSLOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/ServerSSLOptions.java @@ -129,8 +129,18 @@ public ServerSSLOptions setUseAlpn(boolean useAlpn) { } @Override - public ServerSSLOptions setUseHybridKeyExchangeProtocol(boolean useHybridKeyExchangeProtocol) { - return (ServerSSLOptions) super.setUseHybridKeyExchangeProtocol(useHybridKeyExchangeProtocol); + public ServerSSLOptions setKeyExchangeGroups(List keyExchangeGroups) { + return (ServerSSLOptions) super.setKeyExchangeGroups(keyExchangeGroups); + } + + @Override + public ServerSSLOptions addKeyExchangeGroup(String group) { + return (ServerSSLOptions) super.addKeyExchangeGroup(group); + } + + @Override + public ServerSSLOptions setPqcEnforcementPolicy(PqcEnforcementPolicy pqcEnforcementPolicy) { + return (ServerSSLOptions) super.setPqcEnforcementPolicy(pqcEnforcementPolicy); } @Override diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java index f8bc0caf0b3..a5ad3f470e0 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java @@ -34,6 +34,7 @@ import io.vertx.core.internal.ContextInternal; import io.vertx.core.internal.VertxInternal; import io.vertx.core.internal.net.SslChannelProvider; +import io.vertx.core.internal.tls.SslContextManager; import io.vertx.core.internal.tls.SslContextProvider; import io.vertx.core.net.ClientSSLOptions; import io.vertx.core.net.HostAndPort; @@ -117,7 +118,8 @@ private void initSSL(HostAndPort peerAddress, String serverName, boolean ssl, } else { applicationProtocols = null; } - SslChannelProvider sslChannelProvider = new SslChannelProvider(context.owner(), sslContextProvider, false, sslOptions.isUseHybridKeyExchangeProtocol()); + List resolvedGroups = SslContextManager.resolveKeyExchangeGroups(sslOptions.getKeyExchangeGroups(), sslOptions.getPqcEnforcementPolicy()); + SslChannelProvider sslChannelProvider = new SslChannelProvider(context.owner(), sslContextProvider, false, resolvedGroups); SslHandler sslHandler = sslChannelProvider.createClientSslHandler(peerAddress, serverName, applicationProtocols, sslOptions.getSslHandshakeTimeout(), sslOptions.getSslHandshakeTimeoutUnit()); ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("ssl", sslHandler); diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java index f2d4a9fc9cd..95d593a959a 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java @@ -90,7 +90,7 @@ protected void handleClose(Completable completion) { LogConfig logConfig = config.getLogConfig(); this.config = config; this.registerWriteHandler = registerWriteHandler; - this.sslContextManager = new ClientSslContextManager(SslContextManager.resolveEngineOptions(sslEngineOptions, sslOptions != null && sslOptions.isUseAlpn())); + this.sslContextManager = new ClientSslContextManager(SslContextManager.resolveEngineOptions(sslEngineOptions, sslOptions != null && sslOptions.isUseAlpn(), sslOptions != null ? sslOptions.getPqcEnforcementPolicy() : null)); this.metrics = vertx.metrics() != null ? vertx.metrics().createTcpClientMetrics(config, protocol) : null; this.logging = logConfig != null && logConfig.isEnabled() ? logConfig.getDataFormat() : null; this.idleTimeout = config.getIdleTimeout() != null ? config.getIdleTimeout() : Duration.ofMillis(0L); diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetServerImpl.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetServerImpl.java index 30f80b0c2f1..bb9d0adfc52 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetServerImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetServerImpl.java @@ -85,6 +85,7 @@ public class NetServerImpl implements NetServerInternal { // Main private SslContextManager sslContextManager; + private List resolvedKeyExchangeGroups; private SslContextProviderReference sslContextProviderRef; private GlobalTrafficShapingHandler trafficShapingHandler; private ServerChannelLoadBalancer channelBalancer; @@ -236,7 +237,7 @@ private void configurePipeline(Channel ch, SslContextProvider sslContextProvider } else { applicationProtocols = null; } - SslChannelProvider sslChannelProvider = new SslChannelProvider(vertx, sslContextProvider, sslOptions.isSni(), sslOptions.isUseHybridKeyExchangeProtocol()); + SslChannelProvider sslChannelProvider = new SslChannelProvider(vertx, sslContextProvider, sslOptions.isSni(), resolvedKeyExchangeGroups); ch.pipeline().addLast("ssl", sslChannelProvider.createServerHandler(applicationProtocols, sslOptions.getSslHandshakeTimeout(), sslOptions.getSslHandshakeTimeoutUnit(), HttpUtils.socketAddressToHostAndPort(ch.remoteAddress()))); ChannelPromise p = ch.newPromise(); @@ -430,7 +431,8 @@ protected void handleShutdown(Duration timeout, Completable completion) { ServerSslContextManager sslContextManager; try { - sslContextManager = new ServerSslContextManager(SslContextManager.resolveEngineOptions(sslEngineOptions, sslOptions.isUseAlpn())); + sslContextManager = new ServerSslContextManager(SslContextManager.resolveEngineOptions(sslEngineOptions, sslOptions.isUseAlpn(), sslOptions.getPqcEnforcementPolicy())); + this.resolvedKeyExchangeGroups = SslContextManager.resolveKeyExchangeGroups(sslOptions.getKeyExchangeGroups(), sslOptions.getPqcEnforcementPolicy()); } catch (Exception e) { return context.failedFuture(e); } @@ -488,6 +490,7 @@ protected void handleShutdown(Duration timeout, Completable completion) { // Server already exists with that host/port - we will use that actualServer = main; metrics = main.metrics; + resolvedKeyExchangeGroups = main.resolvedKeyExchangeGroups; trafficShapingHandler = main.trafficShapingHandler; initializer = new NetSocketInitializer(context, handler, exceptionHandler, trafficShapingHandler); worker = ch -> { diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java index fe29c570bb2..c719eef7b21 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java @@ -132,13 +132,15 @@ private Future sslUpgrade(String serverName, SSLOptions sslOptions, ByteBu if (sslOptions instanceof ClientSSLOptions) { ClientSSLOptions clientSSLOptions = (ClientSSLOptions) sslOptions; ClientSslContextManager clientSslContextManager = (ClientSslContextManager)sslContextManager; + List resolvedGroups = SslContextManager.resolveKeyExchangeGroups(clientSSLOptions.getKeyExchangeGroups(), clientSSLOptions.getPqcEnforcementPolicy()); f = clientSslContextManager.resolveSslContextProvider(clientSSLOptions, context) - .map(p -> new SslChannelProvider(context.owner(), p, false, clientSSLOptions.isUseHybridKeyExchangeProtocol())); + .map(p -> new SslChannelProvider(context.owner(), p, false, resolvedGroups)); } else { ServerSSLOptions serverSSLOptions = (ServerSSLOptions) sslOptions; ServerSslContextManager serverSslContextManager = (ServerSslContextManager)sslContextManager; + List resolvedGroups = SslContextManager.resolveKeyExchangeGroups(serverSSLOptions.getKeyExchangeGroups(), serverSSLOptions.getPqcEnforcementPolicy()); f = serverSslContextManager.resolveSslContextProvider(serverSSLOptions, context) - .map(p -> new SslChannelProvider(context.owner(), p, serverSSLOptions.isSni(), serverSSLOptions.isUseHybridKeyExchangeProtocol())); + .map(p -> new SslChannelProvider(context.owner(), p, serverSSLOptions.isSni(), resolvedGroups)); } return f.compose(provider -> { PromiseInternal p = context.promise(); diff --git a/vertx-core/src/test/java/io/vertx/it/HybridKeyExchangeTest.java b/vertx-core/src/test/java/io/vertx/it/HybridKeyExchangeTest.java index 73d569b320a..fa66e756b45 100644 --- a/vertx-core/src/test/java/io/vertx/it/HybridKeyExchangeTest.java +++ b/vertx-core/src/test/java/io/vertx/it/HybridKeyExchangeTest.java @@ -23,7 +23,9 @@ import io.netty.internal.tcnative.SSL; import io.vertx.core.http.*; import io.vertx.core.net.ClientSSLOptions; +import io.vertx.core.net.JdkSSLEngineOptions; import io.vertx.core.net.OpenSSLEngineOptions; +import io.vertx.core.net.PqcEnforcementPolicy; import io.vertx.core.net.ServerSSLOptions; import io.vertx.test.tls.Cert; import io.vertx.test.tls.Trust; @@ -31,12 +33,15 @@ import org.junit.Assume; import org.junit.Test; +import io.vertx.core.VertxException; + import javax.net.ssl.SSLHandshakeException; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; /** - * Tests hybrid key exchange (X25519MLKEM768) with OpenSSL. + * Tests PQC key exchange with OpenSSL. */ public class HybridKeyExchangeTest extends HttpTestBase { @@ -74,10 +79,10 @@ private static void assumeMlKemAvailable() { } @Test - public void testHybridKeyExchangeHandshake() throws Exception { + public void testStrictPolicyHandshake() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) .setKeyCertOptions(Cert.SERVER_PEM.get()); server = vertx.httpServerBuilder() @@ -87,25 +92,16 @@ public void testHybridKeyExchangeHandshake() throws Exception { .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); - server.requestHandler(req -> req.response().end("hybrid-ok")); + server.requestHandler(req -> req.response().end("strict-ok")); startServer(server); - ClientSSLOptions hybridClientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + ClientSSLOptions pqcClientSsl = new ClientSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) .setTrustAll(true); client = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) .with(new OpenSSLEngineOptions()) - .with(hybridClientSsl) - .build(); - - ClientSSLOptions nonHybridClientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(false) - .setTrustAll(true); - HttpClientAgent client2 = vertx.httpClientBuilder() - .with(new HttpClientOptions().setSsl(true)) - .with(new OpenSSLEngineOptions()) - .with(nonHybridClientSsl) + .with(pqcClientSsl) .build(); var bodyBuffer = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") @@ -114,7 +110,33 @@ public void testHybridKeyExchangeHandshake() throws Exception { .expecting(HttpResponseExpectation.SC_OK) .compose(HttpClientResponse::body) .await(); - assertEquals("hybrid-ok", bodyBuffer.toString()); + assertEquals("strict-ok", bodyBuffer.toString()); + } + + @Test + public void testStrictPolicyRejectsNonPqcClient() throws Exception { + assumeMlKemAvailable(); + ServerSSLOptions serverSslOptions = new ServerSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setKeyCertOptions(Cert.SERVER_PEM.get()); + + server = vertx.httpServerBuilder() + .with(new HttpServerConfig(new HttpServerOptions() + .setPort(DEFAULT_HTTPS_PORT) + .setHost(DEFAULT_HTTPS_HOST))) + .with(new OpenSSLEngineOptions()) + .with(serverSslOptions) + .build(); + server.requestHandler(req -> req.response().end("should-not-reach")); + startServer(server); + + ClientSSLOptions nonPqcClientSsl = new ClientSSLOptions() + .setTrustAll(true); + HttpClientAgent client2 = vertx.httpClientBuilder() + .with(new HttpClientOptions().setSsl(true)) + .with(new OpenSSLEngineOptions()) + .with(nonPqcClientSsl) + .build(); client2.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") .compose(HttpClientRequest::send) @@ -127,13 +149,12 @@ public void testHybridKeyExchangeHandshake() throws Exception { } @Test - public void testHybridKeyExchangeHandshakeMTLS() throws Exception { + public void testClientNegotiatedPolicyAllowsNonPqcClient() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) - .setClientAuth(io.vertx.core.http.ClientAuth.REQUIRED) - .setKeyCertOptions(Cert.SERVER_PEM_ROOT_CA.get()) - .setTrustOptions(Trust.SERVER_PEM_ROOT_CA.get()); + .setPqcEnforcementPolicy(PqcEnforcementPolicy.CLIENT_NEGOTIATED) + .setKeyExchangeGroups(List.of("X25519")) + .setKeyCertOptions(Cert.SERVER_PEM.get()); server = vertx.httpServerBuilder() .with(new HttpServerConfig(new HttpServerOptions() @@ -142,107 +163,130 @@ public void testHybridKeyExchangeHandshakeMTLS() throws Exception { .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); - server.requestHandler(req -> { - assertTrue(req.isSSL()); - req.response().end("mtls-hybrid-ok"); - }); + server.requestHandler(req -> req.response().end("client-negotiated-ok")); startServer(server); - ClientSSLOptions hybridClientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(true) - .setKeyCertOptions(Cert.CLIENT_PEM_ROOT_CA.get()) + ClientSSLOptions nonPqcClientSsl = new ClientSSLOptions() .setTrustAll(true); client = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) + .with(nonPqcClientSsl) + .build(); + + var bodyBuffer = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") + .compose(HttpClientRequest::send) + .expecting(HttpResponseExpectation.SC_OK) + .compose(HttpClientResponse::body) + .await(); + assertEquals("client-negotiated-ok", bodyBuffer.toString()); + } + + @Test + public void testClientNegotiatedPolicyWithPqcClient() throws Exception { + assumeMlKemAvailable(); + ServerSSLOptions serverSslOptions = new ServerSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.CLIENT_NEGOTIATED) + .setKeyCertOptions(Cert.SERVER_PEM.get()); + + server = vertx.httpServerBuilder() + .with(new HttpServerConfig(new HttpServerOptions() + .setPort(DEFAULT_HTTPS_PORT) + .setHost(DEFAULT_HTTPS_HOST))) .with(new OpenSSLEngineOptions()) - .with(hybridClientSsl) + .with(serverSslOptions) .build(); + server.requestHandler(req -> req.response().end("pqc-negotiated-ok")); + startServer(server); - ClientSSLOptions nonHybridClientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(false) - .setKeyCertOptions(Cert.CLIENT_PEM_ROOT_CA.get()) + ClientSSLOptions pqcClientSsl = new ClientSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) .setTrustAll(true); - HttpClientAgent client2 = vertx.httpClientBuilder() + client = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) .with(new OpenSSLEngineOptions()) - .with(nonHybridClientSsl) + .with(pqcClientSsl) .build(); - var buffer = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") + var bodyBuffer = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") .expecting(req -> req.connection().sslSession().getProtocol().equals("TLSv1.3")) .compose(HttpClientRequest::send) .expecting(HttpResponseExpectation.SC_OK) .compose(HttpClientResponse::body) .await(); - - assertEquals("mtls-hybrid-ok", buffer.toString()); - client2.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") - .compose(HttpClientRequest::send) - .onComplete(ar -> { - assertTrue(ar.failed()); - assertTrue(ar.cause() instanceof SSLHandshakeException); - testComplete(); - }); - await(); + assertEquals("pqc-negotiated-ok", bodyBuffer.toString()); } @Test - public void testHybridFailsServerSideWhenPqcNotAvailable() throws Exception { + public void testStrictPolicyMTLS() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) - .setKeyCertOptions(Cert.SERVER_PEM.get()); + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setClientAuth(io.vertx.core.http.ClientAuth.REQUIRED) + .setKeyCertOptions(Cert.SERVER_PEM_ROOT_CA.get()) + .setTrustOptions(Trust.SERVER_PEM_ROOT_CA.get()); server = vertx.httpServerBuilder() .with(new HttpServerConfig(new HttpServerOptions() .setPort(DEFAULT_HTTPS_PORT) .setHost(DEFAULT_HTTPS_HOST))) + .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); - server.requestHandler(req -> req.response().end("should-not-reach")); + server.requestHandler(req -> { + assertTrue(req.isSSL()); + req.response().end("mtls-strict-ok"); + }); startServer(server); - ClientSSLOptions clientSsl = new ClientSSLOptions() + ClientSSLOptions pqcClientSsl = new ClientSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setKeyCertOptions(Cert.CLIENT_PEM_ROOT_CA.get()) .setTrustAll(true); client = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) - .with(clientSsl) + .with(new OpenSSLEngineOptions()) + .with(pqcClientSsl) .build(); - client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") - .onComplete(ar -> { - assertTrue(ar.failed()); - assertTrue(ar.cause() instanceof SSLHandshakeException); - testComplete(); - client.close(); - server.close(); - }); - await(); + + var buffer = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") + .expecting(req -> req.connection().sslSession().getProtocol().equals("TLSv1.3")) + .compose(HttpClientRequest::send) + .expecting(HttpResponseExpectation.SC_OK) + .compose(HttpClientResponse::body) + .await(); + + assertEquals("mtls-strict-ok", buffer.toString()); } @Test - public void testHybridFailsClientSideWhenPqcNotAvailable() throws Exception { + public void testStrictPolicyMTLSRejectsNonPqcClient() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setKeyCertOptions(Cert.SERVER_PEM.get()); + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setClientAuth(io.vertx.core.http.ClientAuth.REQUIRED) + .setKeyCertOptions(Cert.SERVER_PEM_ROOT_CA.get()) + .setTrustOptions(Trust.SERVER_PEM_ROOT_CA.get()); server = vertx.httpServerBuilder() .with(new HttpServerConfig(new HttpServerOptions() .setPort(DEFAULT_HTTPS_PORT) .setHost(DEFAULT_HTTPS_HOST))) + .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); server.requestHandler(req -> req.response().end("should-not-reach")); startServer(server); - ClientSSLOptions clientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + ClientSSLOptions nonPqcClientSsl = new ClientSSLOptions() + .setKeyCertOptions(Cert.CLIENT_PEM_ROOT_CA.get()) .setTrustAll(true); - client = vertx.httpClientBuilder() + HttpClientAgent client2 = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) - .with(clientSsl) + .with(new OpenSSLEngineOptions()) + .with(nonPqcClientSsl) .build(); - client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") + client2.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") .compose(HttpClientRequest::send) .onComplete(ar -> { assertTrue(ar.failed()); @@ -253,45 +297,90 @@ public void testHybridFailsClientSideWhenPqcNotAvailable() throws Exception { } @Test - public void testHybridFailsWhenPqcNotAvailable() throws Exception { + public void testStrictPolicyFailsServerStartWhenJdkPqcNotAvailable() throws Exception { + Assume.assumeFalse("JDK PQC is available, skipping", JdkSSLEngineOptions.isPqcAvailable()); + ServerSSLOptions serverSslOptions = new ServerSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setKeyCertOptions(Cert.SERVER_PEM.get()); + + try { + server = vertx.httpServerBuilder() + .with(new HttpServerConfig(new HttpServerOptions() + .setPort(DEFAULT_HTTPS_PORT) + .setHost(DEFAULT_HTTPS_HOST))) + .with(new JdkSSLEngineOptions()) + .with(serverSslOptions) + .build(); + server.requestHandler(req -> req.response().end("should-not-reach")); + server.listen().await(); + fail("Server should have failed to start"); + } catch (VertxException e) { + assertTrue(e.getMessage().contains("X25519MLKEM768")); + assertTrue(e.getMessage().contains("does not support it")); + } + } + + @Test + public void testStrictPolicyFailsClientStartWhenJdkPqcNotAvailable() throws Exception { + Assume.assumeFalse("JDK PQC is available, skipping", JdkSSLEngineOptions.isPqcAvailable()); + ClientSSLOptions clientSsl = new ClientSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setTrustAll(true); + + try { + client = vertx.httpClientBuilder() + .with(new HttpClientOptions().setSsl(true)) + .with(new JdkSSLEngineOptions()) + .with(clientSsl) + .build(); + fail("Client should have failed to build"); + } catch (VertxException e) { + assertTrue(e.getMessage().contains("X25519MLKEM768")); + assertTrue(e.getMessage().contains("does not support it")); + } + } + + @Test + public void testStrictPolicyWithSNI() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) + .setSni(true) .setKeyCertOptions(Cert.SERVER_PEM.get()); server = vertx.httpServerBuilder() .with(new HttpServerConfig(new HttpServerOptions() .setPort(DEFAULT_HTTPS_PORT) .setHost(DEFAULT_HTTPS_HOST))) + .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); - server.requestHandler(req -> req.response().end("should-not-reach")); + server.requestHandler(req -> req.response().end("sni-strict-ok")); startServer(server); - ClientSSLOptions clientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + ClientSSLOptions pqcClientSsl = new ClientSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) .setTrustAll(true); client = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) - .with(clientSsl) + .with(new OpenSSLEngineOptions()) + .with(pqcClientSsl) .build(); - client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") + var body = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") .compose(HttpClientRequest::send) - .onComplete(ar -> { - assertTrue(ar.failed()); - assertTrue(ar.cause() instanceof SSLHandshakeException); - testComplete(); - }); - await(); + .expecting(HttpResponseExpectation.SC_OK) + .compose(HttpClientResponse::body) + .await(); + assertEquals("sni-strict-ok", body.toString()); } @Test - public void testHybridKeyExchangeWithSNI() throws Exception { + public void testRelaxedPolicyWithCustomGroups() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) - .setSni(true) + .setPqcEnforcementPolicy(PqcEnforcementPolicy.RELAXED) + .setKeyExchangeGroups(List.of("X25519MLKEM768")) .setKeyCertOptions(Cert.SERVER_PEM.get()); server = vertx.httpServerBuilder() @@ -301,16 +390,17 @@ public void testHybridKeyExchangeWithSNI() throws Exception { .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); - server.requestHandler(req -> req.response().end("sni-hybrid-ok")); + server.requestHandler(req -> req.response().end("relaxed-custom-ok")); startServer(server); - ClientSSLOptions hybridClientSsl = new ClientSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + ClientSSLOptions clientSsl = new ClientSSLOptions() + .setPqcEnforcementPolicy(PqcEnforcementPolicy.RELAXED) + .setKeyExchangeGroups(List.of("X25519MLKEM768")) .setTrustAll(true); client = vertx.httpClientBuilder() .with(new HttpClientOptions().setSsl(true)) .with(new OpenSSLEngineOptions()) - .with(hybridClientSsl) + .with(clientSsl) .build(); var body = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") @@ -318,15 +408,12 @@ public void testHybridKeyExchangeWithSNI() throws Exception { .expecting(HttpResponseExpectation.SC_OK) .compose(HttpClientResponse::body) .await(); - assertEquals("sni-hybrid-ok", body.toString()); + assertEquals("relaxed-custom-ok", body.toString()); } @Test - public void testHybridFailsWithSNIWhenPqcNotAvailable() throws Exception { - assumeMlKemAvailable(); + public void testRelaxedPolicyDefaultGroups() throws Exception { ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) - .setSni(true) .setKeyCertOptions(Cert.SERVER_PEM.get()); server = vertx.httpServerBuilder() @@ -335,7 +422,7 @@ public void testHybridFailsWithSNIWhenPqcNotAvailable() throws Exception { .setHost(DEFAULT_HTTPS_HOST))) .with(serverSslOptions) .build(); - server.requestHandler(req -> req.response().end("should-not-reach")); + server.requestHandler(req -> req.response().end("relaxed-default-ok")); startServer(server); ClientSSLOptions clientSsl = new ClientSSLOptions() @@ -345,21 +432,19 @@ public void testHybridFailsWithSNIWhenPqcNotAvailable() throws Exception { .with(clientSsl) .build(); - client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") + var body = client.request(HttpMethod.GET, DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST, "/") .compose(HttpClientRequest::send) - .onComplete(ar -> { - assertTrue(ar.failed()); - assertTrue(ar.cause() instanceof SSLHandshakeException); - testComplete(); - }); - await(); + .expecting(HttpResponseExpectation.SC_OK) + .compose(HttpClientResponse::body) + .await(); + assertEquals("relaxed-default-ok", body.toString()); } @Test - public void testHybridWithRawNettySocket() throws Exception { + public void testStrictPolicyWithRawNettySocket() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) .setKeyCertOptions(Cert.SERVER_PEM.get()); server = vertx.httpServerBuilder() @@ -369,7 +454,7 @@ public void testHybridWithRawNettySocket() throws Exception { .with(new OpenSSLEngineOptions()) .with(serverSslOptions) .build(); - server.requestHandler(req -> req.response().end("hybrid-ok")); + server.requestHandler(req -> req.response().end("strict-ok")); startServer(server); SslContext sslContext = SslContextBuilder.forClient() @@ -420,10 +505,10 @@ protected void initChannel(SocketChannel ch) { } @Test - public void testHybridMTLSWithRawNettySocket() throws Exception { + public void testStrictPolicyMTLSWithRawNettySocket() throws Exception { assumeMlKemAvailable(); ServerSSLOptions serverSslOptions = new ServerSSLOptions() - .setUseHybridKeyExchangeProtocol(true) + .setPqcEnforcementPolicy(PqcEnforcementPolicy.STRICT) .setClientAuth(io.vertx.core.http.ClientAuth.REQUIRED) .setKeyCertOptions(Cert.SERVER_PEM_ROOT_CA.get()) .setTrustOptions(Trust.SERVER_PEM_ROOT_CA.get()); @@ -437,7 +522,7 @@ public void testHybridMTLSWithRawNettySocket() throws Exception { .build(); server.requestHandler(req -> { assertTrue(req.isSSL()); - req.response().end("mtls-hybrid-ok"); + req.response().end("mtls-strict-ok"); }); startServer(server);