From e165b8468f1758e8afd75074ddd012b19fc979cc Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Mon, 29 Jun 2026 20:42:24 +0000 Subject: [PATCH] feat(javanet): support Conscrypt security provider and PQC hybrid negotiation TAG=agy CONV=385b9ab5-874c-4c9a-b331-66dab51fef61 --- google-http-client/pom.xml | 6 + .../client/http/javanet/NetHttpTransport.java | 98 +++++++- .../javanet/PqcEnforcingSSLSocketFactory.java | 190 +++++++++++++++ .../http/javanet/NetHttpTransportTest.java | 12 + .../PqcEnforcingSSLSocketFactoryTest.java | 220 ++++++++++++++++++ 5 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 google-http-client/src/main/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactory.java create mode 100644 google-http-client/src/test/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactoryTest.java diff --git a/google-http-client/pom.xml b/google-http-client/pom.xml index 16a8dd127..7ccfcf7c0 100644 --- a/google-http-client/pom.xml +++ b/google-http-client/pom.xml @@ -163,6 +163,12 @@ io.opencensus opencensus-contrib-http-util + + org.conscrypt + conscrypt-openjdk-uber + 2.6.0 + provided + com.google.guava diff --git a/google-http-client/src/main/java/com/google/api/client/http/javanet/NetHttpTransport.java b/google-http-client/src/main/java/com/google/api/client/http/javanet/NetHttpTransport.java index 2a0ae6c1f..0c44219fb 100644 --- a/google-http-client/src/main/java/com/google/api/client/http/javanet/NetHttpTransport.java +++ b/google-http-client/src/main/java/com/google/api/client/http/javanet/NetHttpTransport.java @@ -28,12 +28,17 @@ import java.net.URL; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; import java.security.cert.CertificateFactory; import java.util.Arrays; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; +import org.conscrypt.Conscrypt; /** * Thread-safe HTTP low-level transport based on the {@code java.net} package. @@ -81,6 +86,16 @@ private static Proxy defaultProxy() { private static final String SHOULD_USE_PROXY_FLAG = "com.google.api.client.should_use_proxy"; + private static final Logger logger = Logger.getLogger(NetHttpTransport.class.getName()); + + /** + * Post-Quantum Cryptography (PQC) hybrid key exchange groups ordered by preference. + * + *

"X25519MLKEM768" is the primary hybrid post-quantum key exchange algorithm combining X25519 + * elliptic curve with ML-KEM-768. "X25519" is provided as a classical fallback group. + */ + private static final String[] PQC_GROUPS = new String[] {"X25519MLKEM768", "X25519"}; + private final ConnectionFactory connectionFactory; /** SSL socket factory or {@code null} for the default. */ @@ -208,6 +223,13 @@ public static final class Builder { /** Whether the transport is mTLS. Default value is {@code false}. */ private boolean isMtls; + /** + * Security provider to use for SSL context, or {@code null} for the default fallback. If not + * set, {@link NetHttpTransport} defaults to using Conscrypt (if available) and falls back to + * the default JDK provider. + */ + private Provider securityProvider; + /** * Sets the HTTP proxy or {@code null} to use the proxy settings from system @@ -362,14 +384,86 @@ public Builder setHostnameVerifier(HostnameVerifier hostnameVerifier) { return this; } + /** + * Sets the security provider to use for SSL context. + * + *

By default, {@link NetHttpTransport} will attempt to use Conscrypt as the security + * provider. If Conscrypt is not available on the system, it will fall back to the default JDK + * provider. Configuring a custom security provider here will override this default behavior and + * take precedence. + * + * @param securityProvider security provider to use + * @since 1.39 + */ + public Builder setSecurityProvider(Provider securityProvider) { + this.securityProvider = securityProvider; + return this; + } + + /** + * Resolves the {@link SSLSocketFactory} to use, prioritizing user-configured factory, then + * custom security provider, defaulting to Conscrypt, and falling back to JDK. + */ + private SSLSocketFactory resolveSslSocketFactory() { + if (sslSocketFactory != null) { + return new PqcEnforcingSSLSocketFactory(sslSocketFactory, PQC_GROUPS); + } + return new PqcEnforcingSSLSocketFactory(createDefaultSslSocketFactory(), PQC_GROUPS); + } + + /** + * Instantiates and initializes a default {@link SSLSocketFactory} utilizing the resolved + * security provider and default TrustManagers. Falls back to the system default socket factory + * if initialization fails. + */ + private SSLSocketFactory createDefaultSslSocketFactory() { + try { + SSLContext sslContext = createSslContext(); + sslContext.init(null, null, null); + return sslContext.getSocketFactory(); + } catch (Exception e) { + // If SSLContext initialization fails entirely, fall back to the JVM's default + // system-wide SSLSocketFactory. + return (SSLSocketFactory) SSLSocketFactory.getDefault(); + } + } + + /** + * Resolves and instantiates the {@link SSLContext} for "TLS", prioritizing the configured + * custom security provider, falling back to inserting and loading the Conscrypt provider, and + * defaulting to the JRE's standard SSLContext if Conscrypt is unavailable. + */ + private SSLContext createSslContext() throws GeneralSecurityException { + // 1. If a custom security provider is configured, use it + if (securityProvider != null) { + return SSLContext.getInstance("TLS", securityProvider); + } + // 2. Default: Try Conscrypt (assumed to be available as part of SDK) + try { + if (Security.getProvider("Conscrypt") == null) { + Security.insertProviderAt(Conscrypt.newProvider(), 1); + } + return SSLContext.getInstance("TLS", "Conscrypt"); + } catch (Throwable e) { + logger.log( + Level.WARNING, + "Conscrypt security provider not available. Falling back to JDK default.", + e); + } + // 3. Fallback to standard JDK + return SSLContext.getInstance("TLS"); + } + /** Returns a new instance of {@link NetHttpTransport} based on the options. */ public NetHttpTransport build() { if (System.getProperty(SHOULD_USE_PROXY_FLAG) != null) { setProxy(defaultProxy()); } + SSLSocketFactory resolvedSslSocketFactory = resolveSslSocketFactory(); return this.proxy == null - ? new NetHttpTransport(connectionFactory, sslSocketFactory, hostnameVerifier, isMtls) - : new NetHttpTransport(this.proxy, sslSocketFactory, hostnameVerifier, isMtls); + ? new NetHttpTransport( + connectionFactory, resolvedSslSocketFactory, hostnameVerifier, isMtls) + : new NetHttpTransport(this.proxy, resolvedSslSocketFactory, hostnameVerifier, isMtls); } } } diff --git a/google-http-client/src/main/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactory.java b/google-http-client/src/main/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactory.java new file mode 100644 index 000000000..a06dfa431 --- /dev/null +++ b/google-http-client/src/main/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactory.java @@ -0,0 +1,190 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.api.client.http.javanet; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.Socket; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import org.conscrypt.Conscrypt; + +/** + * An {@link SSLSocketFactory} wrapper that configures PQC curves on Conscrypt sockets. + * + *

If the socket is detected as a Conscrypt socket, it configures the requested named groups + * using Conscrypt's direct APIs. If the socket or provider does not support these groups, it falls + * back to the default TLS negotiation of the delegate factory. + */ +final class PqcEnforcingSSLSocketFactory extends SSLSocketFactory { + private final SSLSocketFactory delegate; + private final String[] groups; + + PqcEnforcingSSLSocketFactory(SSLSocketFactory delegate, String[] groups) { + this.delegate = delegate; + this.groups = groups; + } + + private static final Logger logger = + Logger.getLogger(PqcEnforcingSSLSocketFactory.class.getName()); + + private static final boolean CAN_USE_JDK_NAMED_GROUPS_API = checkJdkNamedGroupsApiAvailability(); + + private static final AtomicBoolean loggedPqcWarning = new AtomicBoolean(false); + + /** + * Checks whether the standard JCA {@link SSLParameters} API contains the {@code setNamedGroups} + * method. + * + *

This capability is required to programmatically enforce or prioritize PQC curves (like + * X25519MLKEM768) when using non-Conscrypt security providers (such as Bouncy Castle or standard + * JRE SunJSSE). + * + *

Because this library compiles to Java 8 bytecode target, the standard {@code setNamedGroups} + * method (introduced in Java 20) is not available at compile time. This reflection capability + * check runs once on class loading to check availability, allowing us to safely skip runtime + * reflection calls and avoid throwing expensive {@link NoSuchMethodException}s on older JVMs. + */ + private static boolean checkJdkNamedGroupsApiAvailability() { + try { + SSLParameters.class.getMethod("setNamedGroups", String[].class); + return true; + } catch (Throwable t) { + return false; + } + } + + /** + * Configures post-quantum cryptography (PQC) hybrid curves on the socket if it is an SSLSocket + * managed by Conscrypt. + * + *

If the socket is an instance of {@link SSLSocket} and Conscrypt is the active provider + * backing it, this method invokes Conscrypt's direct APIs to set the prioritized named groups + * (curves) to negotiate during the TLS handshake (e.g., X25519MLKEM768 and classical X25519). + * + *

It catches {@link Throwable} to ensure that if Conscrypt native JNI libraries fail to link + * or load (which throws linkage errors like {@link UnsatisfiedLinkError}), the configuration + * falls back silently to default TLS negotiation instead of failing the connection. + */ + private Socket configure(Socket socket) { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + try { + // 1. If it is a Conscrypt socket, use Conscrypt's direct JNI API first + if (Conscrypt.isConscrypt(sslSocket)) { + Conscrypt.setNamedGroups(sslSocket, groups); + return socket; + } + // 2. Otherwise (user-provided security provider or JDK fallback), try standard JSSE + // reflection (Java 20+) + if (CAN_USE_JDK_NAMED_GROUPS_API) { + trySetNamedGroupsViaReflection(sslSocket, groups); + } else { + if (loggedPqcWarning.compareAndSet(false, true)) { + logger.log( + Level.WARNING, + "The Java 20+ SSLParameters.setNamedGroups API is not available on this JRE, " + + "and Conscrypt is not active. Cannot configure PQC curve negotiation; " + + "falling back to the standard TLS connection configuration."); + } + } + } catch (Throwable ignored) { + // Fall back silently if not supported by the provider/platform + } + } + return socket; + } + + /** + * Configures named groups (curves) on standard JSSE socket parameters using reflection. + * + *

We must use reflection to invoke {@code SSLParameters.setNamedGroups} because the + * compilation target of this library is Java 8, which does not contain the standard {@code + * setNamedGroups} API (introduced in Java 20). + * + *

Using reflection allows compilation against Java 8 while enabling the application to + * dynamically configure post-quantum curve negotiation at runtime if it is executed in a Java 20+ + * environment with a compliant JCA provider (such as Bouncy Castle or standard SunJSSE). + */ + private static boolean trySetNamedGroupsViaReflection(SSLSocket sslSocket, String[] groups) { + if (!CAN_USE_JDK_NAMED_GROUPS_API) { + return false; + } + try { + Object sslParameters = sslSocket.getClass().getMethod("getSSLParameters").invoke(sslSocket); + if (sslParameters != null) { + Method setNamedGroupsMethod = + sslParameters.getClass().getMethod("setNamedGroups", String[].class); + setNamedGroupsMethod.invoke(sslParameters, (Object) groups); + sslSocket + .getClass() + .getMethod("setSSLParameters", sslParameters.getClass()) + .invoke(sslSocket, sslParameters); + return true; + } + } catch (Throwable ignored) { + // Reflection failed (e.g. method not found or security manager restrictions) + } + return false; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) + throws IOException { + return configure(delegate.createSocket(s, host, port, autoClose)); + } + + @Override + public Socket createSocket() throws IOException { + return configure(delegate.createSocket()); + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + return configure(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException { + return configure(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return configure(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return configure(delegate.createSocket(address, port, localAddress, localPort)); + } +} diff --git a/google-http-client/src/test/java/com/google/api/client/http/javanet/NetHttpTransportTest.java b/google-http-client/src/test/java/com/google/api/client/http/javanet/NetHttpTransportTest.java index 87c5337c6..b51ded1ac 100644 --- a/google-http-client/src/test/java/com/google/api/client/http/javanet/NetHttpTransportTest.java +++ b/google-http-client/src/test/java/com/google/api/client/http/javanet/NetHttpTransportTest.java @@ -38,6 +38,8 @@ import java.security.KeyStore; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.security.Provider; +import java.security.Security; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -241,4 +243,14 @@ public void handle(HttpExchange httpExchange) throws IOException { response.disconnect(); } } + + @Test + public void testCustomSecurityProvider() throws Exception { + Provider customProvider = new Provider("TestProvider", 1.0, "Test Provider") {}; + NetHttpTransport transport = new NetHttpTransport.Builder() + .setSecurityProvider(customProvider) + .build(); + // Verify it compiles and builds successfully with a custom provider + assertTrue(transport != null); + } } diff --git a/google-http-client/src/test/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactoryTest.java b/google-http-client/src/test/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactoryTest.java new file mode 100644 index 000000000..9e998e733 --- /dev/null +++ b/google-http-client/src/test/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactoryTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.api.client.http.javanet; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import javax.net.ssl.HandshakeCompletedListener; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class PqcEnforcingSSLSocketFactoryTest { + + private static final String[] TEST_GROUPS = new String[] {"X25519MLKEM768", "X25519"}; + + @Test + public void testDelegationAndNonSslSocket() throws Exception { + Socket plainSocket = new Socket(); + FakeSSLSocketFactory delegate = new FakeSSLSocketFactory(plainSocket); + PqcEnforcingSSLSocketFactory factory = new PqcEnforcingSSLSocketFactory(delegate, TEST_GROUPS); + + Socket result = factory.createSocket(); + assertSame(plainSocket, result); + assertTrue(delegate.createSocketCalled); + } + + @Test + public void testConfigureNonConscryptSslSocket() throws Exception { + FakeSSLSocket sslSocket = new FakeSSLSocket(); + FakeSSLSocketFactory delegate = new FakeSSLSocketFactory(sslSocket); + PqcEnforcingSSLSocketFactory factory = new PqcEnforcingSSLSocketFactory(delegate, TEST_GROUPS); + + Socket result = factory.createSocket(); + assertSame(sslSocket, result); + assertTrue(delegate.createSocketCalled); + } + + @Test + public void testConfigureConscryptSslSocket() throws Exception { + try { + java.security.Provider provider = org.conscrypt.Conscrypt.newProvider(); + SSLContext sslContext = SSLContext.getInstance("TLS", provider); + sslContext.init(null, null, null); + } catch (Throwable t) { + org.junit.Assume.assumeNoException( + "Conscrypt JNI library is not available on this platform", t); + } + + java.security.Provider provider = org.conscrypt.Conscrypt.newProvider(); + SSLContext sslContext = SSLContext.getInstance("TLS", provider); + sslContext.init(null, null, null); + SSLSocketFactory conscryptFactory = sslContext.getSocketFactory(); + + PqcEnforcingSSLSocketFactory factory = + new PqcEnforcingSSLSocketFactory(conscryptFactory, TEST_GROUPS); + + Socket socket = factory.createSocket(); + try { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + assertTrue(org.conscrypt.Conscrypt.isConscrypt(sslSocket)); + } + } finally { + socket.close(); + } + } + + private static class FakeSSLSocketFactory extends SSLSocketFactory { + private final Socket socketToReturn; + boolean createSocketCalled = false; + + FakeSSLSocketFactory(Socket socketToReturn) { + this.socketToReturn = socketToReturn; + } + + @Override + public String[] getDefaultCipherSuites() { + return new String[0]; + } + + @Override + public String[] getSupportedCipherSuites() { + return new String[0]; + } + + @Override + public Socket createSocket() throws IOException { + createSocketCalled = true; + return socketToReturn; + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) + throws IOException { + createSocketCalled = true; + return socketToReturn; + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + createSocketCalled = true; + return socketToReturn; + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException { + createSocketCalled = true; + return socketToReturn; + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + createSocketCalled = true; + return socketToReturn; + } + + @Override + public Socket createSocket( + InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { + createSocketCalled = true; + return socketToReturn; + } + } + + private static class FakeSSLSocket extends SSLSocket { + @Override + public String[] getSupportedCipherSuites() { + return new String[0]; + } + + @Override + public String[] getEnabledCipherSuites() { + return new String[0]; + } + + @Override + public void setEnabledCipherSuites(String[] suites) {} + + @Override + public String[] getSupportedProtocols() { + return new String[0]; + } + + @Override + public String[] getEnabledProtocols() { + return new String[0]; + } + + @Override + public void setEnabledProtocols(String[] protocols) {} + + @Override + public SSLSession getSession() { + return null; + } + + @Override + public void addHandshakeCompletedListener(HandshakeCompletedListener listener) {} + + @Override + public void removeHandshakeCompletedListener(HandshakeCompletedListener listener) {} + + @Override + public void startHandshake() throws IOException {} + + @Override + public void setUseClientMode(boolean mode) {} + + @Override + public boolean getUseClientMode() { + return false; + } + + @Override + public void setNeedClientAuth(boolean need) {} + + @Override + public boolean getNeedClientAuth() { + return false; + } + + @Override + public void setWantClientAuth(boolean want) {} + + @Override + public boolean getWantClientAuth() { + return false; + } + + @Override + public void setEnableSessionCreation(boolean flag) {} + + @Override + public boolean getEnableSessionCreation() { + return false; + } + } +}