diff --git a/google-http-client/pom.xml b/google-http-client/pom.xml index 16a8dd127..fd97d76ae 100644 --- a/google-http-client/pom.xml +++ b/google-http-client/pom.xml @@ -189,5 +189,6 @@ opencensus-testing test + 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..8953ead58 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,6 +28,7 @@ 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 javax.net.ssl.HostnameVerifier; @@ -79,6 +80,12 @@ private static Proxy defaultProxy() { Arrays.sort(SUPPORTED_METHODS); } + private static final String TLS_ALGORITHM = "TLS"; + + private static final String[] PQC_GROUPS = { + "X25519MLKEM768", "SecP256r1MLKEM768", "X25519Kyber768Draft00", "X25519" + }; + private static final String SHOULD_USE_PROXY_FLAG = "com.google.api.client.should_use_proxy"; private final ConnectionFactory connectionFactory; @@ -189,6 +196,12 @@ public static final class Builder { /** SSL socket factory or {@code null} for the default. */ private SSLSocketFactory sslSocketFactory; + /** Security provider to use or {@code null} for default. */ + private Provider securityProvider; + + /** Custom named groups to configure on sockets, or {@code null} to disable wrapper. */ + private String[] namedGroups = null; + /** Host name verifier or {@code null} for the default. */ private HostnameVerifier hostnameVerifier; @@ -351,6 +364,27 @@ public Builder setSslSocketFactory(SSLSocketFactory sslSocketFactory) { return this; } + /** + * Sets the custom security provider or {@code null} to use default. + * + * @param securityProvider provider to use + */ + public Builder setSecurityProvider(Provider securityProvider) { + this.securityProvider = securityProvider; + return this; + } + + /** + * Sets the custom named groups (curves) to negotiate on TLS sockets, or {@code null} to disable + * custom named groups configuration. + * + * @param namedGroups named groups to prioritize + */ + public Builder setNamedGroups(String[] namedGroups) { + this.namedGroups = namedGroups; + return this; + } + /** Returns the host name verifier or {@code null} for the default. */ public HostnameVerifier getHostnameVerifier() { return hostnameVerifier; @@ -362,14 +396,43 @@ public Builder setHostnameVerifier(HostnameVerifier hostnameVerifier) { return this; } + private SSLSocketFactory resolveSslSocketFactory() { + SSLSocketFactory factory = sslSocketFactory; + if (factory == null) { + factory = createDefaultSslSocketFactory(); + } + if (namedGroups != null && namedGroups.length > 0) { + return new TlsParametersSSLSocketFactory(factory, namedGroups); + } + return factory; + } + + private SSLSocketFactory createDefaultSslSocketFactory() { + try { + SSLContext sslContext = createSslContext(); + sslContext.init(null, null, null); + return sslContext.getSocketFactory(); + } catch (Exception e) { + return (SSLSocketFactory) SSLSocketFactory.getDefault(); + } + } + + private SSLContext createSslContext() throws GeneralSecurityException { + if (securityProvider != null) { + return SSLContext.getInstance(TLS_ALGORITHM, securityProvider); + } + 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 resolvedFactory = resolveSslSocketFactory(); return this.proxy == null - ? new NetHttpTransport(connectionFactory, sslSocketFactory, hostnameVerifier, isMtls) - : new NetHttpTransport(this.proxy, sslSocketFactory, hostnameVerifier, isMtls); + ? new NetHttpTransport(connectionFactory, resolvedFactory, hostnameVerifier, isMtls) + : new NetHttpTransport(this.proxy, resolvedFactory, hostnameVerifier, isMtls); } } } diff --git a/google-http-client/src/main/java/com/google/api/client/http/javanet/TlsParametersSSLSocketFactory.java b/google-http-client/src/main/java/com/google/api/client/http/javanet/TlsParametersSSLSocketFactory.java new file mode 100644 index 000000000..2c5d29c8c --- /dev/null +++ b/google-http-client/src/main/java/com/google/api/client/http/javanet/TlsParametersSSLSocketFactory.java @@ -0,0 +1,177 @@ +/* + * 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 intercepts socket creation to configure custom TLS + * parameters, such as prioritized named groups (elliptic curves/post-quantum cryptography groups), + * using standard Java Cryptography Architecture (JCA) JSSE APIs. + * + *

Since standard Java {@link javax.net.ssl.HttpURLConnection} does not expose connection-level + * hooks to modify socket TLS parameters, wrapping the socket factory is the only standard way to + * configure custom TLS settings programmatically before the handshake begins. + * + *

This factory utilizes standard JRE APIs (specifically {@link SSLParameters#setNamedGroups}) + * via reflection. If the running JRE version is below Java 20 (where the named groups API is not + * available), this factory will bypass reflection and fall back quietly to JRE TLS defaults. + */ +final class TlsParametersSSLSocketFactory extends SSLSocketFactory { + private static final Logger logger = + Logger.getLogger(TlsParametersSSLSocketFactory.class.getName()); + + /** + * Cache indicating whether the standard {@code SSLParameters.setNamedGroups} API is supported. + */ + private static final boolean CAN_USE_JDK_NAMED_GROUPS_API = checkJdkNamedGroupsApiAvailability(); + + private static final AtomicBoolean loggedPqcWarning = new AtomicBoolean(false); + private static final AtomicBoolean loggedReflectionWarning = new AtomicBoolean(false); + + private final SSLSocketFactory delegate; + private final String[] groups; + + /** + * Constructs a new factory wrapping the delegate factory. + * + * @param delegate the base SSLSocketFactory that handles raw socket creation. + * @param groups the array of TLS named groups (elliptic curves/PQC algorithms) to prioritize. + */ + TlsParametersSSLSocketFactory(SSLSocketFactory delegate, String[] groups) { + this.delegate = delegate; + this.groups = groups; + } + + /** Checks if {@code SSLParameters.setNamedGroups(String[])} is present on the classpath. */ + private static boolean checkJdkNamedGroupsApiAvailability() { + try { + SSLParameters.class.getMethod("setNamedGroups", String[].class); + return true; + } catch (Throwable t) { + return false; + } + } + + /** + * Intercepts socket creation and configures custom TLS parameters on the socket if it is an + * {@link SSLSocket}. + */ + private Socket configure(Socket socket) { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + try { + // Try standard JCA named groups API via 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. Cannot" + + " configure custom TLS named groups (PQC); falling back to the standard TLS" + + " connection configuration."); + } + } + } catch (Throwable t) { + if (loggedReflectionWarning.compareAndSet(false, true)) { + logger.log( + Level.WARNING, + "Failed to configure custom TLS named groups (PQC) via standard JSSE reflection " + + "parameters; falling back to the standard TLS connection configuration.", + t); + } + } + } + return socket; + } + + /** + * Dynamically retrieves the {@code SSLParameters} of the socket, reflectively invokes {@code + * setNamedGroups(String[])} on it, and reapplies the updated parameters to the socket. + * + * @param sslSocket the active SSLSocket connection. + * @param groups the ordered array of curve names to configure. + * @return true if parameters were successfully retrieved and applied, false otherwise. + * @throws Throwable if reflection execution fails during method retrieval or invocation. + */ + private static boolean trySetNamedGroupsViaReflection(SSLSocket sslSocket, String[] groups) + throws Throwable { + 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; + } + return false; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return configure(delegate.createSocket()); + } + + @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(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..7c2a074eb 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,7 @@ import java.security.KeyStore; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import javax.net.ssl.SSLSocketFactory; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -237,8 +238,43 @@ public void handle(HttpExchange httpExchange) throws IOException { testUrl.setPort(server.getPort()); com.google.api.client.http.HttpResponse response = transport.createRequestFactory().buildGetRequest(testUrl).execute(); - // disconnect should not wait to read the entire content response.disconnect(); } } + + @Test + public void testBuilderDefaultNamedGroupsIsNull() throws Exception { + NetHttpTransport.Builder builder = new NetHttpTransport.Builder(); + String[] namedGroups = (String[]) getPrivateField(builder, "namedGroups"); + org.junit.Assert.assertNull(namedGroups); + } + + @Test + public void testBuilderConfigureNamedGroups() throws Exception { + NetHttpTransport.Builder builder = + new NetHttpTransport.Builder().setNamedGroups(new String[] {"X25519MLKEM768", "X25519"}); + String[] namedGroups = (String[]) getPrivateField(builder, "namedGroups"); + org.junit.Assert.assertArrayEquals(new String[] {"X25519MLKEM768", "X25519"}, namedGroups); + } + + @Test + public void testTransportResolveSslSocketFactoryWithDefault() throws Exception { + NetHttpTransport transport = new NetHttpTransport.Builder().build(); + SSLSocketFactory factory = (SSLSocketFactory) getPrivateField(transport, "sslSocketFactory"); + assertFalse(factory instanceof TlsParametersSSLSocketFactory); + } + + @Test + public void testTransportResolveSslSocketFactoryWithCustom() throws Exception { + NetHttpTransport transport = + new NetHttpTransport.Builder().setNamedGroups(new String[] {"X25519MLKEM768"}).build(); + SSLSocketFactory factory = (SSLSocketFactory) getPrivateField(transport, "sslSocketFactory"); + assertTrue(factory instanceof TlsParametersSSLSocketFactory); + } + + private static Object getPrivateField(Object obj, String fieldName) throws Exception { + java.lang.reflect.Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(obj); + } } diff --git a/google-http-client/src/test/java/com/google/api/client/http/javanet/TlsParametersSSLSocketFactoryTest.java b/google-http-client/src/test/java/com/google/api/client/http/javanet/TlsParametersSSLSocketFactoryTest.java new file mode 100644 index 000000000..4df0e34b6 --- /dev/null +++ b/google-http-client/src/test/java/com/google/api/client/http/javanet/TlsParametersSSLSocketFactoryTest.java @@ -0,0 +1,189 @@ +/* + * 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.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 TlsParametersSSLSocketFactoryTest { + + 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); + TlsParametersSSLSocketFactory factory = new TlsParametersSSLSocketFactory(delegate, TEST_GROUPS); + + Socket result = factory.createSocket(); + assertSame(plainSocket, result); + assertTrue(delegate.createSocketCalled); + } + + @Test + public void testConfigureNonSslSocket() throws Exception { + FakeSSLSocket sslSocket = new FakeSSLSocket(); + FakeSSLSocketFactory delegate = new FakeSSLSocketFactory(sslSocket); + TlsParametersSSLSocketFactory factory = new TlsParametersSSLSocketFactory(delegate, TEST_GROUPS); + + Socket result = factory.createSocket(); + assertSame(sslSocket, result); + assertTrue(delegate.createSocketCalled); + } + + 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..bd77fddbd 100644 --- a/pom.xml +++ b/pom.xml @@ -291,6 +291,7 @@ opencensus-testing ${project.opencensus.version} + @@ -574,6 +575,7 @@ 5.3.1 5.2.5 0.31.1 + .. 3.5.2 false