From 0f4489ce2d2a6e92c50464da0bcafa0b6c62bdc3 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 custom TLS named groups configuration TAG=agy CONV=385b9ab5-874c-4c9a-b331-66dab51fef61 --- google-http-client/pom.xml | 12 + .../client/http/javanet/NetHttpTransport.java | 145 +++++++++++- .../javanet/PqcEnforcingSSLSocketFactory.java | 207 ++++++++++++++++ .../http/javanet/NetHttpTransportTest.java | 25 ++ .../PqcEnforcingSSLSocketFactoryTest.java | 220 ++++++++++++++++++ pom.xml | 7 + 6 files changed, 614 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..f376bfcac 100644 --- a/google-http-client/pom.xml +++ b/google-http-client/pom.xml @@ -163,6 +163,18 @@ io.opencensus opencensus-contrib-http-util + + + org.conscrypt + conscrypt-openjdk-uber + 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..be51fc261 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.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.SSLParameters; 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,31 @@ 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 static final String TLS_ALGORITHM = "TLS"; + + private static final String CONSCRYPT_PROVIDER = "Conscrypt"; + + private static final boolean CAN_USE_JDK_NAMED_GROUPS_API = checkJdkNamedGroupsApiAvailability(); + + private static boolean checkJdkNamedGroupsApiAvailability() { + try { + SSLParameters.class.getMethod("setNamedGroups", String[].class); + return true; + } catch (Throwable t) { + return false; + } + } + private final ConnectionFactory connectionFactory; /** SSL socket factory or {@code null} for the default. */ @@ -208,6 +238,16 @@ 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; + + /** Named groups to enforce on the SSL socket. Defaults to {@code PQC_GROUPS}. */ + private String[] namedGroups = PQC_GROUPS.clone(); + /** * Sets the HTTP proxy or {@code null} to use the proxy settings from system @@ -362,14 +402,115 @@ 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; + } + + /** + * Sets the custom TLS named groups (curves) to configure on SSL sockets created by this + * transport. + * + *

By default, this is initialized to include the Post-Quantum hybrid curve {@code + * "X25519MLKEM768"} and classical fallback curve {@code "X25519"}. + * + *

Pass a custom list to override these defaults. Pass {@code null} or an empty array to + * disable custom named groups configuration entirely (preventing socket wrapping and Conscrypt + * JCA provider instantiation). + * + * @param namedGroups TLS named groups to configure on sockets, or {@code null} to disable + * @return this builder + */ + public Builder setNamedGroups(String[] namedGroups) { + this.namedGroups = namedGroups == null ? null : namedGroups.clone(); + 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() { + SSLSocketFactory factory = sslSocketFactory; + if (factory == null) { + factory = createDefaultSslSocketFactory(); + } + if (namedGroups == null || namedGroups.length == 0) { + return factory; + } + return new PqcEnforcingSSLSocketFactory(factory, namedGroups); + } + + /** + * 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_ALGORITHM, securityProvider); + } + // 2. Default: Try Conscrypt (assumed to be available as part of SDK) + if (namedGroups != null && namedGroups.length > 0) { + try { + return SSLContext.getInstance(TLS_ALGORITHM, Conscrypt.newProvider()); + } catch (Throwable e) { + if (CAN_USE_JDK_NAMED_GROUPS_API) { + logger.log( + Level.INFO, + "Conscrypt not available. Falling back to JDK default (will use native JRE named" + + " groups)."); + } else { + logger.log( + Level.WARNING, + "Conscrypt security provider not available. Falling back to JDK default.", + e); + } + } + } + // 3. Fallback to standard JDK + return SSLContext.getInstance(TLS_ALGORITHM); + } + /** 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..8670214e4 --- /dev/null +++ b/google-http-client/src/main/java/com/google/api/client/http/javanet/PqcEnforcingSSLSocketFactory.java @@ -0,0 +1,207 @@ +/* + * 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; + +/** + * 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 static final Logger logger = + Logger.getLogger(PqcEnforcingSSLSocketFactory.class.getName()); + + private static final boolean CAN_USE_JDK_NAMED_GROUPS_API = checkJdkNamedGroupsApiAvailability(); + + private static final Class conscryptClass = resolveConscryptClass(); + private static final Method isConscryptMethod = resolveIsConscryptMethod(); + private static final Method setNamedGroupsMethod = resolveSetNamedGroupsMethod(); + + private static final AtomicBoolean loggedPqcWarning = new AtomicBoolean(false); + + private static final AtomicBoolean loggedReflectionWarning = new AtomicBoolean(false); + + private final SSLSocketFactory delegate; + private final String[] groups; + + PqcEnforcingSSLSocketFactory(SSLSocketFactory delegate, String[] groups) { + this.delegate = delegate; + this.groups = groups; + } + + private static Class resolveConscryptClass() { + try { + return Class.forName("org.conscrypt.Conscrypt"); + } catch (Throwable ignored) { + return null; + } + } + + private static Method resolveIsConscryptMethod() { + if (conscryptClass == null) { + return null; + } + try { + return conscryptClass.getMethod("isConscrypt", SSLSocket.class); + } catch (Throwable ignored) { + return null; + } + } + + private static Method resolveSetNamedGroupsMethod() { + if (conscryptClass == null) { + return null; + } + try { + return conscryptClass.getMethod("setNamedGroups", SSLSocket.class, String[].class); + } catch (Throwable ignored) { + return null; + } + } + + /** + * Checks whether the standard JCA {@link SSLParameters} API contains the {@code setNamedGroups} + * method. + */ + 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. + */ + private Socket configure(Socket socket) { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + try { + // 1. If Conscrypt is active and class resolved, use reflection + if (isConscryptMethod != null && setNamedGroupsMethod != null) { + if ((Boolean) isConscryptMethod.invoke(null, sslSocket)) { + setNamedGroupsMethod.invoke(null, sslSocket, (Object) 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. */ + 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 t) { + if (loggedReflectionWarning.compareAndSet(false, true)) { + logger.log( + Level.WARNING, + "Failed to configure custom named groups (PQC) via standard JSSE reflection parameters." + + " Cannot configure PQC curve negotiation; falling back to the standard TLS" + + " connection configuration.", + t); + } + } + 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..737120ef7 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 @@ -36,6 +36,7 @@ import java.net.InetSocketAddress; import java.net.URL; import java.security.KeyStore; +import java.security.Provider; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.junit.Test; @@ -241,4 +242,28 @@ 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); + } + + @Test + public void testSetNamedGroups() throws Exception { + NetHttpTransport transport = + new NetHttpTransport.Builder() + .setNamedGroups(new String[] {"X25519MLKEM768", "X25519"}) + .build(); + assertTrue(transport != null); + } + + @Test + public void testDisableNamedGroups() throws Exception { + NetHttpTransport transport = new NetHttpTransport.Builder().setNamedGroups(null).build(); + 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; + } + } +} diff --git a/pom.xml b/pom.xml index 4155d00a5..27286719e 100644 --- a/pom.xml +++ b/pom.xml @@ -291,6 +291,12 @@ opencensus-testing ${project.opencensus.version} + + org.conscrypt + conscrypt-openjdk-uber + ${project.conscrypt.version} + provided + @@ -574,6 +580,7 @@ 5.3.1 5.2.5 0.31.1 + 2.6.0 .. 3.5.2 false