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 @@
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 @@