Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ java_library(
srcs = glob(["src/test/java/**/*.java"]),
javacopts = _JAVA_COPTS,
plugins = ["//:auto_value_plugin"],
resources = glob([
"src/test/resources/com/google/api/gax/httpjson/tls13TestCa.pem",
"src/test/resources/com/google/api/gax/httpjson/tls13TestServerCertAndKey.pem",
]),
visibility = ["//visibility:public"],
deps = [":gax_httpjson"] + _COMPILE_DEPS + _TEST_COMPILE_DEPS,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

Expand Down Expand Up @@ -218,18 +220,28 @@ private NetHttpTransport.Builder configureMtls(NetHttpTransport.Builder builder)
// Fall back to standard JDK JSSE if Conscrypt provider is unavailable
return builder;
}
// Explicitly initialize SSLContext with the Conscrypt provider so that the client certificate
// key managers
// and trust manager factory (TMF) are bound to Conscrypt's TLS implementation (supporting PQC
// key exchange).
// NetHttpTransport.Builder.trustCertificates() in google-http-client initializes with the
// default SunX509 KeyManagerFactory (via SslUtils.getDefaultKeyManagerFactory()), which is not
// supported by Conscrypt. Explicitly initialize SSLContext with the Conscrypt provider so that
// the client certificate key managers and trust manager factory (TMF) are bound to Conscrypt's
// TLS implementation (supporting PQC key exchange).
SSLContext sslContext = SSLContext.getInstance("TLS", conscryptProvider);
// The TrustManagerFactory must come from the same provider as the SSLContext. On TLS 1.3,
// Conscrypt passes authType "GENERIC" to the trust manager, which the JDK (SunJSSE) PKIX
// trust manager rejects for CA-issued server certificates that carry a KeyUsage extension
// (e.g. Google front ends), failing the handshake with "Unknown authType: GENERIC".
// Conscrypt's trust manager loads the same default trust store as the JDK.
// The KeyManagerFactory must also come from Conscrypt: since JDK 26 (JDK-8359956) the JDK's
// SunX509 key manager applies algorithm constraints to the client certificate, and when called
// from a Conscrypt handshake it rejects valid certificates (e.g. SHA256withRSA), so no client
// certificate is sent.
SslUtils.initSslContext(
sslContext,
null,
SslUtils.getPkixTrustManagerFactory(),
TrustManagerFactory.getInstance("PKIX", conscryptProvider),
mtlsKeyStore,
"",
SslUtils.getDefaultKeyManagerFactory());
KeyManagerFactory.getInstance("PKIX", conscryptProvider));
Comment on lines 238 to +244

@lqiu96 lqiu96 Oct 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this fix. Looks like would be ideal if the upstream http-client initialized with PKIX by default, so this could all just be done within builder.trustCertificates. In my mind, we don't need this override (L219-244). But since SslUtils.getDefaultKeyManagerFactory() looks to default with SunX509 and changing that is a behavior breaking change, I think this is the best we can do.

Can you add a small note of the above. e.g. Http-client looks to initialize with the default of SunX509 which is not supported by Conscrypt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note in the comment explaining that google-http-client's trustCertificates() initializes with the default SunX509 KeyManagerFactory, which is not supported by Conscrypt.

builder.setSslSocketFactory(sslContext.getSocketFactory());
return builder;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/*
* Copyright 2026 Google LLC
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google LLC nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.google.api.gax.httpjson;

import static com.google.common.truth.Truth.assertThat;

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.util.SecurityUtils;
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
import com.google.api.gax.rpc.testing.FakeMtlsProvider;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;

/**
* Handshake test for the Conscrypt-backed mTLS {@link HttpTransport} built by {@link
* InstantiatingHttpJsonChannelProvider}.
*
* <p>Regression test for "Unknown authType: GENERIC": on TLS 1.3, Conscrypt passes the authType
* {@code "GENERIC"} to its trust manager. If the trust manager comes from the JDK (SunJSSE) rather
* than Conscrypt, it rejects server certificates that are issued by a trusted CA and carry a
* KeyUsage extension (as Google front ends' certificates do), and every mTLS HTTP/JSON handshake
* fails. A self-signed server certificate that is itself the trust anchor does not reproduce the
* issue, so the server here presents a CA-issued leaf with KeyUsage.
*
* <p>The transport always uses the default trust store, so this test points {@code
* javax.net.ssl.trustStore} at a temporary store containing only the test CA, and restores the
* previous value afterwards.
*/
@EnabledIf(
value = "isConscryptAndTls13Available",
disabledReason = "Conscrypt native library or TLS 1.3 is unavailable on this platform")
class InstantiatingHttpJsonChannelProviderTls13Test {

private static final String RESOURCE_DIR = "com/google/api/gax/httpjson/";
private static final String TRUST_STORE_PROPERTY = "javax.net.ssl.trustStore";
private static final String TRUST_STORE_PASSWORD_PROPERTY = "javax.net.ssl.trustStorePassword";
private static final String TRUST_STORE_PASSWORD = "changeit";

@TempDir File tempDir;

private String previousTrustStore;
private String previousTrustStorePassword;
private ExecutorService serverExecutor;

/** Condition for {@link EnabledIf}; must be static because it is used at class level. */
static boolean isConscryptAndTls13Available() {
return HttpJsonConscryptUtils.getConscryptProvider() != null && isTls13Supported();
}

@BeforeEach
void setUp() throws Exception {
previousTrustStore = System.getProperty(TRUST_STORE_PROPERTY);
previousTrustStorePassword = System.getProperty(TRUST_STORE_PASSWORD_PROPERTY);
File trustStoreFile = writeTrustStoreWithTestCa();
System.setProperty(TRUST_STORE_PROPERTY, trustStoreFile.getAbsolutePath());
System.setProperty(TRUST_STORE_PASSWORD_PROPERTY, TRUST_STORE_PASSWORD);

serverExecutor = Executors.newSingleThreadExecutor();
}

@AfterEach
void tearDown() {
restoreProperty(TRUST_STORE_PROPERTY, previousTrustStore);
restoreProperty(TRUST_STORE_PASSWORD_PROPERTY, previousTrustStorePassword);
if (serverExecutor != null) {
serverExecutor.shutdownNow();
}
}

@Test
void createHttpTransport_withMtlsAndConscrypt_completesTls13HandshakeWithCaIssuedServerCert()
throws Exception {
CertificateBasedAccess certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class);
Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
InstantiatingHttpJsonChannelProvider channelProvider =
InstantiatingHttpJsonChannelProvider.newBuilder()
.setEndpoint("localhost:443")
.setMtlsProvider(
new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false))
.setCertificateBasedAccess(certificateBasedAccess)
.build();
HttpTransport transport = channelProvider.createHttpTransport();
assertThat(transport).isNotNull();

final SSLServerSocket serverSocket = createTls13ServerSocket();
try {
Future<Boolean> clientCertificatePresented =
serverExecutor.submit(() -> serveSingleRequest(serverSocket));

HttpResponse response =
transport
.createRequestFactory()
.buildGetRequest(
new GenericUrl("https://localhost:" + serverSocket.getLocalPort() + "/"))
.execute();
try {
assertThat(response.getStatusCode()).isEqualTo(200);
assertThat(response.parseAsString()).isEqualTo("ok");
} finally {
response.disconnect();
}
// execute() is synchronous so the server task is already done; timeout guards against hangs.
assertThat(clientCertificatePresented.get(10, TimeUnit.SECONDS)).isTrue();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at the logic, this doesn't seem like it would take that long. Is this to guard against it running forever? If it does take an unknown amount of time, can we use awaitility here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's just a fail-safe bound on Future.get() to guard against hanging forever if the server thread stalls. Since .execute() above is synchronous, the server has already completed the handshake and written the response by the time we reach this line, so Future.get() returns immediately without polling (and awaitility isn't currently on the gax-httpjson Maven/Bazel test classpaths). Added a one-line comment above it to clarify.

} finally {
serverSocket.close();
}
}

/** Accepts one connection, answers one HTTP request, and reports if a client cert was sent. */
private static boolean serveSingleRequest(SSLServerSocket serverSocket) throws Exception {
SSLSocket socket = (SSLSocket) serverSocket.accept();
try {
socket.startHandshake();
boolean clientCertificatePresented = socket.getSession().getPeerCertificates().length > 0;
BufferedReader reader =
new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
String line;
while ((line = reader.readLine()) != null && !line.isEmpty()) {
// Drain request headers.
}
OutputStream out = socket.getOutputStream();
Comment thread
lqiu96 marked this conversation as resolved.
out.write(
("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
.getBytes(StandardCharsets.US_ASCII));
out.flush();
return clientCertificatePresented;
} finally {
socket.close();
}
}

/**
* Creates a JDK TLS 1.3 server that presents a CA-issued leaf certificate with KeyUsage and
* requires (but does not validate) a client certificate.
*/
private static SSLServerSocket createTls13ServerSocket() throws Exception {
KeyStore serverKeyStore;
InputStream certAndKey = openResource("tls13TestServerCertAndKey.pem");
try {
serverKeyStore = SecurityUtils.createMtlsKeyStore(certAndKey);
} finally {
certAndKey.close();
}
KeyManagerFactory keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagerFactory.init(serverKeyStore, new char[0]);

SSLContext serverContext = SSLContext.getInstance("TLSv1.3");
serverContext.init(
keyManagerFactory.getKeyManagers(), new TrustManager[] {new AcceptAllTrustManager()}, null);
SSLServerSocket serverSocket =
(SSLServerSocket)
serverContext
.getServerSocketFactory()
.createServerSocket(0, 1, InetAddress.getLoopbackAddress());
serverSocket.setEnabledProtocols(new String[] {"TLSv1.3"});
serverSocket.setNeedClientAuth(true);
return serverSocket;
}

private File writeTrustStoreWithTestCa() throws Exception {
X509Certificate caCertificate;
InputStream caPem = openResource("tls13TestCa.pem");
try {
caCertificate =
(X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(caPem);
} finally {
caPem.close();
}
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setCertificateEntry("gax-test-ca", caCertificate);
File trustStoreFile = new File(tempDir, "truststore");
OutputStream out = new FileOutputStream(trustStoreFile);
try {
trustStore.store(out, TRUST_STORE_PASSWORD.toCharArray());
} finally {
out.close();
}
return trustStoreFile;
}

private static InputStream openResource(String name) {
InputStream stream =
InstantiatingHttpJsonChannelProviderTls13Test.class
.getClassLoader()
.getResourceAsStream(RESOURCE_DIR + name);
if (stream == null) {
throw new IllegalStateException("Missing test resource: " + RESOURCE_DIR + name);
}
return stream;
}

private static boolean isTls13Supported() {
try {
SSLContext.getInstance("TLSv1.3");
return true;
} catch (Exception e) {
return false;
}
}

private static void restoreProperty(String key, String value) {
if (value == null) {
System.clearProperty(key);
} else {
System.setProperty(key, value);
}
}

/** Server-side trust manager: the test only checks that a client certificate is presented. */
private static final class AcceptAllTrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {}

@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDHzCCAgegAwIBAgIUZEyXXbTT3KXPPzN6Z6b30/GQ26wwDQYJKoZIhvcNAQEL
BQAwFjEUMBIGA1UEAwwLR0FYIFRlc3QgQ0EwIBcNMjYwOTMwMDMzNzM5WhgPMjEy
NjA5MDYwMzM3MzlaMBYxFDASBgNVBAMMC0dBWCBUZXN0IENBMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkLx+Kh78x1ktPpvGf3ITPS7xwr7rU6Pt/Z34
AKodCSBCrt/d3BhkGiy8w3IqWN7mIy2ujdoKeUau4NJiYdz3A16XZ1nhvp7J3tK5
a6SSxO95lVwy70nafTO8ApFYKawKLKNWvGqChM95AI6+Q7dTzzGArpwJxCK0p7dp
4bKjhpSOZaW46VdpCsLwoPJmXNlDpAL5O3nrvdnfilHVCqWHoAZk7ESwQcwm8vP6
5suLUSvpagPfMRabFTEv91PsmVhB8KjSu8/kGPAC1F7RLyZK31a0Kb5wNwW+69cb
kxjUEhNux7/SHTF250kF2vQ+nFrnUQZSGM8cHU8qpeaOsTFDRwIDAQABo2MwYTAd
BgNVHQ4EFgQU7pHELECvL5hdK8Y3N1x6GZSzSjkwHwYDVR0jBBgwFoAU7pHELECv
L5hdK8Y3N1x6GZSzSjkwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
DQYJKoZIhvcNAQELBQADggEBACCyzuu0K4HF0iMu1rj+adlpYUzl6uwkKBuL6fRJ
sTxe0ftgWWcFXJV+P6T+maGu7DMANqbADpWqQbAHMNbSXxPSfGgOd2tS+jg/OOef
go1MhsMUpgxSN9PvWAfhiVIhgF7pdjdVP2qiObx7F/Qo/Xmr0MEy61mmeCbWENBj
jC7DsCo5SWo6kMjvb6dz5G+f+4LElQKCQbBGAOm00XlERE/W7WfUMBzNFhLCHrVs
CRq5lIGxek8DEIC8oXtt/7g0KoKVkOCNj2ZXljEQgi4awoYHqlTRXw/nlctJJ8xs
DLmvfUObHIo66rliDG1Pco/Ce9FVewUJz2WQXJK/mGuuesA=
-----END CERTIFICATE-----
Loading
Loading