diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java
new file mode 100644
index 000000000000..d8f51c61935f
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java
@@ -0,0 +1,173 @@
+/*
+ * 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.auth.mtls;
+
+import com.google.api.client.util.PemReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * Builds the mTLS {@link KeyStore} from PEM-encoded certificates and a private key.
+ *
+ *
This replaces {@code com.google.api.client.util.SecurityUtils#createMtlsKeyStore}, which keeps
+ * only the first certificate. Here the key entry holds every distinct certificate in the input, so
+ * the intermediate certificates are presented too. Some peers (for example, another workload that
+ * trusts only a root CA) need them to verify the client certificate. Because every certificate in
+ * the input is sent in the TLS handshake, the input should hold only the leaf certificate and its
+ * intermediates.
+ *
+ *
Everything else matches the upstream method: key store type (JKS), alias, empty password,
+ * accepted key format (PKCS#8 {@code PRIVATE KEY}), which private key is chosen, and error
+ * messages. Any input that the upstream method accepted is still accepted, even though more of it
+ * is now used: once a certificate and a private key have been found (the point where the upstream
+ * method stopped reading), a malformed later PEM section ends the input instead of failing, and an
+ * extra certificate that cannot be parsed is left out of the chain. Unlike the upstream method, the
+ * input is decoded as UTF-8 rather than the platform default charset, and it is read to the end.
+ */
+@NullMarked
+final class MtlsKeyStoreUtils {
+
+ private static final Logger LOGGER = Logger.getLogger(MtlsKeyStoreUtils.class.getName());
+
+ private MtlsKeyStoreUtils() {}
+
+ /**
+ * Creates a key store holding one private key entry.
+ *
+ * @param certAndKey PEM input containing one or more {@code CERTIFICATE} sections and a PKCS#8
+ * {@code PRIVATE KEY} section, in any order. The first certificate must be the one that
+ * matches the private key (the leaf); the remaining distinct certificates are kept in input
+ * order as the rest of the chain. If there are several private keys, the one used is the same
+ * as upstream: the last one before the first certificate, or else the first one after it. The
+ * stream is read to the end and is not closed.
+ * @throws IllegalArgumentException if the input has no certificate or no private key, or if a PEM
+ * section before both a certificate and a private key have been read is malformed
+ * @throws GeneralSecurityException if the first certificate or the private key cannot be parsed,
+ * or the key store cannot be created
+ * @throws IOException if the input cannot be read
+ */
+ static KeyStore createMtlsKeyStore(InputStream certAndKey)
+ throws GeneralSecurityException, IOException {
+ List certificates = new ArrayList<>();
+ byte[] privateKey = null;
+ PemReader reader = new PemReader(new InputStreamReader(certAndKey, StandardCharsets.UTF_8));
+ while (true) {
+ PemReader.Section section;
+ try {
+ section = reader.readNextSection();
+ } catch (IllegalArgumentException e) {
+ if (!certificates.isEmpty() && privateKey != null) {
+ // Malformed trailing content after a usable certificate and key: stop here, as the
+ // upstream method (which stopped reading at this point) would have.
+ break;
+ }
+ throw e;
+ }
+ if (section == null) {
+ break;
+ }
+ if ("CERTIFICATE".equals(section.getTitle())) {
+ byte[] certBytes = section.getBase64DecodedBytes();
+ if (!containsCertificate(certificates, certBytes)) {
+ certificates.add(certBytes);
+ }
+ } else if ("PRIVATE KEY".equals(section.getTitle())
+ && (privateKey == null || certificates.isEmpty())) {
+ // Same choice as upstream: a later key replaces an earlier one until the first certificate
+ // has been seen.
+ privateKey = section.getBase64DecodedBytes();
+ }
+ }
+ if (certificates.isEmpty()) {
+ throw new IllegalArgumentException("certificate is missing from certAndKey string");
+ }
+ if (privateKey == null) {
+ throw new IllegalArgumentException("private key is missing from certAndKey string");
+ }
+
+ CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
+ List chain = new ArrayList<>(certificates.size());
+ chain.add(parseCertificate(certFactory, certificates.get(0)));
+ for (int i = 1; i < certificates.size(); i++) {
+ try {
+ chain.add(parseCertificate(certFactory, certificates.get(i)));
+ } catch (CertificateException | RuntimeException e) {
+ // The upstream method never parsed extra certificates; leave this one out rather than
+ // fail.
+ LOGGER.log(Level.FINE, "Skipping unparsable extra certificate in mTLS chain", e);
+ }
+ }
+ PrivateKey key =
+ KeyFactory.getInstance(chain.get(0).getPublicKey().getAlgorithm())
+ .generatePrivate(new PKCS8EncodedKeySpec(privateKey));
+
+ KeyStore keyStore = KeyStore.getInstance("JKS");
+ keyStore.load(null);
+ keyStore.setKeyEntry("alias", key, new char[] {}, chain.toArray(new X509Certificate[0]));
+ return keyStore;
+ }
+
+ private static boolean containsCertificate(List certificates, byte[] candidate) {
+ for (byte[] existing : certificates) {
+ if (Arrays.equals(existing, candidate)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static X509Certificate parseCertificate(CertificateFactory certFactory, byte[] der)
+ throws CertificateException {
+ Certificate cert = certFactory.generateCertificate(new ByteArrayInputStream(der));
+ if (cert == null) {
+ throw new CertificateException("Empty certificate");
+ }
+ return (X509Certificate) cert;
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
index 06294c611c7b..b030a992a9dd 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
@@ -32,10 +32,11 @@
import com.google.api.client.json.JsonParser;
import com.google.api.client.json.gson.GsonFactory;
-import com.google.api.client.util.SecurityUtils;
import com.google.api.core.InternalApi;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -147,8 +148,25 @@ static KeyStore getKeyStore(InputStream metadata, ProcessProvider processProvide
"SecureConnect: Cert provider command failed with exit code: " + exitCode);
}
- // Create mTLS key store with the input certificates from shell command.
- return SecurityUtils.createMtlsKeyStore(process.getInputStream());
+ // Create mTLS key store with the input certificates from shell command. Read only the bytes
+ // already buffered now that the process has exited, so a background child process holding
+ // stdout open cannot block waiting for EOF.
+ ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+ try (InputStream processOut = process.getInputStream()) {
+ if (processOut != null) {
+ for (int available = processOut.available();
+ available > 0;
+ available = processOut.available()) {
+ byte[] buffer = new byte[available];
+ int read = processOut.read(buffer);
+ if (read <= 0) {
+ break;
+ }
+ stdout.write(buffer, 0, read);
+ }
+ }
+ }
+ return MtlsKeyStoreUtils.createMtlsKeyStore(new ByteArrayInputStream(stdout.toByteArray()));
}
@VisibleForTesting
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java
index e6f529b087be..fa7deeda21ca 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java
@@ -30,7 +30,6 @@
package com.google.auth.mtls;
-import com.google.api.client.util.SecurityUtils;
import com.google.api.core.InternalApi;
import com.google.auth.oauth2.EnvironmentProvider;
import com.google.auth.oauth2.PropertyProvider;
@@ -143,7 +142,8 @@ public X509Provider() {
* which contains both the certificate chain and the private key.
*
*
- * @return a KeyStore containing the X.509 certificate specified by the certificate configuration.
+ * @return a KeyStore containing the X.509 certificate chain and private key from the certificate
+ * source above.
* @throws CertificateSourceUnavailableException if the certificate source is unavailable (ex.
* missing configuration file)
* @throws IOException if a general I/O error occurs while creating the KeyStore
@@ -169,7 +169,7 @@ public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOEx
MtlsUtils.getGkeCredentialBundlePath(envProvider, propProvider);
if (gkeCredentialBundlePath != null) {
try (InputStream bundleStream = new FileInputStream(new File(gkeCredentialBundlePath))) {
- return SecurityUtils.createMtlsKeyStore(bundleStream);
+ return MtlsKeyStoreUtils.createMtlsKeyStore(bundleStream);
} catch (FileNotFoundException e) {
// The bundle was removed (e.g. mid-rotation) after the availability check.
throw new CertificateSourceUnavailableException(
@@ -201,7 +201,7 @@ public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOEx
new SequenceInputStream(certStream, privateKeyStream)) {
// Build a key store using the combined stream.
- return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream);
+ return MtlsKeyStoreUtils.createMtlsKeyStore(certAndPrivateKeyStream);
} catch (CertificateSourceUnavailableException e) {
// Throw the CertificateSourceUnavailableException without wrapping.
throw e;
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java
new file mode 100644
index 000000000000..5f1e8fca0545
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java
@@ -0,0 +1,345 @@
+/*
+ * 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.auth.mtls;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.util.SecurityUtils;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+
+class MtlsKeyStoreUtilsTest {
+
+ // Leaf (CN=Test Leaf) followed by the intermediate that signed it (CN=Test Intermediate CA).
+ static final String CHAIN_CERT_PATH = "testresources/mtls/test_chain_cert.pem";
+ // PKCS#8 private key for the leaf in CHAIN_CERT_PATH.
+ static final String CHAIN_KEY_PATH = "testresources/mtls/test_chain_key.pem";
+ private static final String SINGLE_CERT_PATH = "testresources/mtls/test_cert.pem";
+ private static final String SINGLE_KEY_PATH = "testresources/mtls/test_key.pem";
+
+ @Test
+ void createMtlsKeyStore_certificatesFirst_keepsFullChainInOrder() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_keyFirst_keepsFullChainInOrder() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_KEY_PATH), read(CHAIN_CERT_PATH)));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_threeCertificates_keepsFullChainInOrder() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(read(CHAIN_CERT_PATH), read(SINGLE_CERT_PATH), read(CHAIN_KEY_PATH)));
+
+ Certificate[] chain = keyStore.getCertificateChain(onlyAlias(keyStore));
+ assertEquals(3, chain.length);
+ assertEquals("CN=Test Leaf", subject(chain[0]));
+ assertEquals("CN=Test Intermediate CA", subject(chain[1]));
+ assertEquals(
+ subject(
+ SecurityUtils.createMtlsKeyStore(stream(read(SINGLE_CERT_PATH), read(SINGLE_KEY_PATH)))
+ .getCertificate("alias")),
+ subject(chain[2]));
+ }
+
+ @Test
+ void createMtlsKeyStore_duplicateCertificates_deduplicated() throws Exception {
+ // Simulates X509Provider concatenating cert_path and key_path when both point to the same
+ // combined PEM bundle.
+ String combinedBundle = read(CHAIN_CERT_PATH) + "\n" + read(CHAIN_KEY_PATH);
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(combinedBundle, combinedBundle));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void upstreamCreateMtlsKeyStore_keepsOnlyLeaf() throws Exception {
+ // Documents why MtlsKeyStoreUtils exists. If google-http-client starts keeping the full chain,
+ // this fails and the helper can be replaced by the upstream method again.
+ KeyStore keyStore =
+ SecurityUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+
+ assertEquals(1, keyStore.getCertificateChain(onlyAlias(keyStore)).length);
+ }
+
+ @Test
+ void createMtlsKeyStore_privateKeyMatchesLeaf() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+ String alias = onlyAlias(keyStore);
+ PrivateKey key = (PrivateKey) keyStore.getKey(alias, new char[] {});
+ Certificate leaf = keyStore.getCertificateChain(alias)[0];
+
+ byte[] data = "chain".getBytes(UTF_8);
+ Signature signer = Signature.getInstance("SHA256withECDSA");
+ signer.initSign(key);
+ signer.update(data);
+ byte[] signature = signer.sign();
+ Signature verifier = Signature.getInstance("SHA256withECDSA");
+ verifier.initVerify(leaf.getPublicKey());
+ verifier.update(data);
+ assertTrue(verifier.verify(signature));
+ }
+
+ @Test
+ void createMtlsKeyStore_singleCertificate_sameAsBefore() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(SINGLE_CERT_PATH), read(SINGLE_KEY_PATH)));
+ KeyStore expected =
+ SecurityUtils.createMtlsKeyStore(stream(read(SINGLE_CERT_PATH), read(SINGLE_KEY_PATH)));
+
+ String alias = onlyAlias(keyStore);
+ assertEquals(Collections.list(expected.aliases()), Collections.list(keyStore.aliases()));
+ assertEquals(expected.getType(), keyStore.getType());
+ Certificate[] chain = keyStore.getCertificateChain(alias);
+ assertEquals(1, chain.length);
+ assertArrayEquals(expected.getCertificate(alias).getEncoded(), chain[0].getEncoded());
+ assertArrayEquals(
+ expected.getKey(alias, new char[] {}).getEncoded(),
+ keyStore.getKey(alias, new char[] {}).getEncoded());
+ }
+
+ @Test
+ void createMtlsKeyStore_ignoresOtherSections() throws Exception {
+ String other = "-----BEGIN EC PARAMETERS-----\nBggqhkjOPQMBBw==\n-----END EC PARAMETERS-----\n";
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(other, read(CHAIN_CERT_PATH), other, read(CHAIN_KEY_PATH)));
+
+ assertEquals(2, keyStore.getCertificateChain(onlyAlias(keyStore)).length);
+ }
+
+ @Test
+ void createMtlsKeyStore_keysBeforeCertificate_usesLastKeyLikeUpstream() throws Exception {
+ // RSA key, then the leaf's EC key, then the certificates: the key just before the first
+ // certificate is used.
+ String input =
+ String.join("\n", read(SINGLE_KEY_PATH), read(CHAIN_KEY_PATH), read(CHAIN_CERT_PATH));
+
+ KeyStore keyStore = MtlsKeyStoreUtils.createMtlsKeyStore(stream(input));
+ KeyStore upstream = SecurityUtils.createMtlsKeyStore(stream(input));
+
+ byte[] expectedKeyBytes = expectedChainKeyEncoded();
+ assertArrayEquals(
+ expectedKeyBytes, keyStore.getKey(onlyAlias(keyStore), new char[] {}).getEncoded());
+ assertArrayEquals(
+ expectedKeyBytes, upstream.getKey(onlyAlias(upstream), new char[] {}).getEncoded());
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_keysBeforeCertificate_lastKeyDoesNotMatch_throwsLikeUpstream()
+ throws Exception {
+ // The leaf's EC key, then an RSA key, then the certificates: the RSA key is chosen and cannot
+ // be read as an EC key, in both implementations.
+ String input =
+ String.join("\n", read(CHAIN_KEY_PATH), read(SINGLE_KEY_PATH), read(CHAIN_CERT_PATH));
+
+ assertThrows(
+ InvalidKeySpecException.class, () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(input)));
+ assertThrows(
+ InvalidKeySpecException.class, () -> SecurityUtils.createMtlsKeyStore(stream(input)));
+ }
+
+ @Test
+ void createMtlsKeyStore_keysAfterCertificate_usesFirstKeyLikeUpstream() throws Exception {
+ String input =
+ String.join("\n", read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH), read(SINGLE_KEY_PATH));
+
+ KeyStore keyStore = MtlsKeyStoreUtils.createMtlsKeyStore(stream(input));
+ KeyStore upstream = SecurityUtils.createMtlsKeyStore(stream(input));
+
+ byte[] expectedKeyBytes = expectedChainKeyEncoded();
+ assertArrayEquals(
+ expectedKeyBytes, keyStore.getKey(onlyAlias(keyStore), new char[] {}).getEncoded());
+ assertArrayEquals(
+ expectedKeyBytes, upstream.getKey(onlyAlias(upstream), new char[] {}).getEncoded());
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_malformedTrailingSection_ignored() throws Exception {
+ // A section with no end tag after a usable certificate and key. The upstream method stopped
+ // reading before reaching it, so it must not cause a failure now. Putting the intermediate
+ // after the key also checks that reading continues past the first certificate and key.
+ String[] leafAndIntermediate =
+ read(CHAIN_CERT_PATH).split("(?<=-----END CERTIFICATE-----)\r?\n");
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(
+ leafAndIntermediate[0],
+ read(CHAIN_KEY_PATH),
+ leafAndIntermediate[1],
+ "-----BEGIN CERTIFICATE-----\nAAAA"));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_malformedSectionAfterOnlyCertificate_throws() throws Exception {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(read(CHAIN_CERT_PATH), "-----BEGIN PRIVATE KEY-----\nAAAA")));
+ }
+
+ @Test
+ void createMtlsKeyStore_malformedSectionAfterOnlyKey_throws() throws Exception {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(read(CHAIN_KEY_PATH), "-----BEGIN CERTIFICATE-----\nAAAA")));
+ }
+
+ @Test
+ void createMtlsKeyStore_ioExceptionAfterCertAndKey_throws() throws Exception {
+ String[] leafAndIntermediate =
+ read(CHAIN_CERT_PATH).split("(?<=-----END CERTIFICATE-----)\r?\n");
+ byte[] validPrefix = (read(CHAIN_KEY_PATH) + "\n" + leafAndIntermediate[0]).getBytes(UTF_8);
+ InputStream failingStream =
+ new FilterInputStream(new ByteArrayInputStream(validPrefix)) {
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int n = super.read(b, off, len);
+ if (n == -1) {
+ throw new IOException("disk read error while reading intermediate");
+ }
+ return n;
+ }
+ };
+
+ assertThrows(IOException.class, () -> MtlsKeyStoreUtils.createMtlsKeyStore(failingStream));
+ }
+
+ @Test
+ void createMtlsKeyStore_unparsableExtraCertificate_leftOut() throws Exception {
+ String[] leafAndIntermediate =
+ read(CHAIN_CERT_PATH).split("(?<=-----END CERTIFICATE-----)\r?\n");
+ String bad = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----";
+ String empty = "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----";
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(
+ leafAndIntermediate[0], bad, empty, leafAndIntermediate[1], read(CHAIN_KEY_PATH)));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_unparsableLeaf_throws() throws Exception {
+ String bad = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----";
+ assertThrows(
+ CertificateException.class,
+ () ->
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(bad, read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH))));
+ }
+
+ @Test
+ void createMtlsKeyStore_missingCertificate_throws() throws Exception {
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_KEY_PATH))));
+ assertEquals("certificate is missing from certAndKey string", e.getMessage());
+ }
+
+ @Test
+ void createMtlsKeyStore_missingPrivateKey_throws() throws Exception {
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH))));
+ assertEquals("private key is missing from certAndKey string", e.getMessage());
+ }
+
+ static String read(String path) throws IOException {
+ return new String(Files.readAllBytes(new File(path).toPath()), UTF_8);
+ }
+
+ /** Asserts the key store's single entry holds the test leaf followed by its intermediate. */
+ static void assertLeafThenIntermediate(KeyStore keyStore) throws Exception {
+ Certificate[] chain = keyStore.getCertificateChain(onlyAlias(keyStore));
+ assertEquals(2, chain.length);
+ assertEquals("CN=Test Leaf", subject(chain[0]));
+ assertEquals("CN=Test Intermediate CA", subject(chain[1]));
+ }
+
+ private static ByteArrayInputStream stream(String... parts) {
+ return new ByteArrayInputStream(String.join("\n", parts).getBytes(UTF_8));
+ }
+
+ private static String onlyAlias(KeyStore keyStore) throws Exception {
+ assertEquals(1, keyStore.size());
+ String alias = keyStore.aliases().nextElement();
+ assertNotNull(keyStore.getCertificateChain(alias));
+ return alias;
+ }
+
+ private static byte[] expectedChainKeyEncoded() throws Exception {
+ KeyStore expected =
+ SecurityUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+ return expected.getKey(onlyAlias(expected), new char[] {}).getEncoded();
+ }
+
+ private static String subject(Certificate certificate) {
+ return ((X509Certificate) certificate).getSubjectX500Principal().getName();
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java
index fab805fa9520..87e1e23a8336 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java
@@ -35,9 +35,12 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.google.auth.TestUtils;
+import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.security.KeyStore;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -47,10 +50,16 @@ private static class TestCertProviderCommandProcess extends Process {
private final boolean runForever;
private final int exitValue;
+ private final InputStream stdout;
public TestCertProviderCommandProcess(int exitValue, boolean runForever) {
+ this(exitValue, runForever, null);
+ }
+
+ TestCertProviderCommandProcess(int exitValue, boolean runForever, InputStream stdout) {
this.runForever = runForever;
this.exitValue = exitValue;
+ this.stdout = stdout;
}
@Override
@@ -60,7 +69,7 @@ public OutputStream getOutputStream() {
@Override
public InputStream getInputStream() {
- return null;
+ return stdout;
}
@Override
@@ -101,6 +110,55 @@ public Process createProcess(InputStream metadata) throws IOException {
}
}
+ @Test
+ void testGetKeyStore_certificateChain_keepsFullChain() throws Exception {
+ String stdout =
+ MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH)
+ + "\n"
+ + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH);
+ SecureConnectProvider.ProcessProvider processProvider =
+ metadata ->
+ new TestCertProviderCommandProcess(0, false, TestUtils.stringToInputStream(stdout));
+
+ KeyStore store =
+ SecureConnectProvider.getKeyStore(TestUtils.stringToInputStream("{}"), processProvider);
+
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ }
+
+ @Test
+ void testGetKeyStore_stdoutHeldOpenAfterBufferedBytes_doesNotReadToEof() throws Exception {
+ String stdout =
+ MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH)
+ + "\n"
+ + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH);
+ InputStream neverEndingStdout =
+ new FilterInputStream(TestUtils.stringToInputStream(stdout)) {
+ @Override
+ public int read() throws IOException {
+ if (available() == 0) {
+ throw new AssertionError("Attempted to read past buffered stdout bytes");
+ }
+ return super.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (available() == 0) {
+ throw new AssertionError("Attempted to read past buffered stdout bytes");
+ }
+ return super.read(b, off, len);
+ }
+ };
+ SecureConnectProvider.ProcessProvider processProvider =
+ metadata -> new TestCertProviderCommandProcess(0, false, neverEndingStdout);
+
+ KeyStore store =
+ SecureConnectProvider.getKeyStore(TestUtils.stringToInputStream("{}"), processProvider);
+
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ }
+
@Test
void testGetKeyStoreNonZeroExitCode() {
try (InputStream metadata =
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java
index 493189b68c7a..e9d7182cf310 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java
@@ -246,6 +246,68 @@ void x509Provider_noConfig_usesGkeCredentialBundleWithKeyFirst(@TempDir Path tem
}
}
+ @Test
+ void x509Provider_gkeCredentialBundleWithIntermediate_keepsFullChain(@TempDir Path tempDir)
+ throws Exception {
+ Path bundle =
+ writeGkeBundle(
+ tempDir,
+ MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH,
+ MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH,
+ /* keyFirst= */ false);
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ KeyStore store = testProvider.getKeyStore();
+ // The intermediate is presented too, so peers that only trust the root can verify the leaf.
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_keyFirstGkeCredentialBundleWithIntermediate_keepsFullChain(
+ @TempDir Path tempDir) throws Exception {
+ // GKE pod certificate bundles put the private key before the certificate chain.
+ Path bundle =
+ writeGkeBundle(
+ tempDir,
+ MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH,
+ MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH,
+ /* keyFirst= */ true);
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(testProvider.getKeyStore());
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_certFileWithIntermediate_keepsFullChain(@TempDir Path tempDir)
+ throws Exception {
+ Path config = tempDir.resolve("certificate_config.json");
+ Files.write(
+ config,
+ ("{\"cert_configs\":{\"workload\":{\"cert_path\":\""
+ + MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH
+ + "\",\"key_path\":\""
+ + MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH
+ + "\"}}}")
+ .getBytes(UTF_8));
+
+ X509Provider testProvider = new X509Provider(config.toString());
+
+ KeyStore store = testProvider.getKeyStore();
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ }
+
@Test
void x509Provider_explicitConfigEnv_takesPrecedenceOverGkeCredentialBundle(@TempDir Path tempDir)
throws Exception {
@@ -356,12 +418,23 @@ private static Certificate loadTestCert() throws IOException, CertificateExcepti
}
private static Path writeGkeBundle(Path dir, boolean keyFirst) throws IOException {
- return writeGkeBundleAt(dir.resolve("x509.credential-bundle.private-key.pem"), keyFirst);
+ return writeGkeBundle(dir, TEST_CERT_PATH, TEST_KEY_PATH, keyFirst);
+ }
+
+ private static Path writeGkeBundle(Path dir, String certPath, String keyPath, boolean keyFirst)
+ throws IOException {
+ return writeGkeBundleAt(
+ dir.resolve("x509.credential-bundle.private-key.pem"), certPath, keyPath, keyFirst);
}
private static Path writeGkeBundleAt(Path bundle, boolean keyFirst) throws IOException {
- String cert = new String(Files.readAllBytes(new File(TEST_CERT_PATH).toPath()), UTF_8);
- String key = new String(Files.readAllBytes(new File(TEST_KEY_PATH).toPath()), UTF_8);
+ return writeGkeBundleAt(bundle, TEST_CERT_PATH, TEST_KEY_PATH, keyFirst);
+ }
+
+ private static Path writeGkeBundleAt(
+ Path bundle, String certPath, String keyPath, boolean keyFirst) throws IOException {
+ String cert = new String(Files.readAllBytes(new File(certPath).toPath()), UTF_8);
+ String key = new String(Files.readAllBytes(new File(keyPath).toPath()), UTF_8);
String content = keyFirst ? key + "\n" + cert : cert + "\n" + key;
Files.write(bundle, content.getBytes(UTF_8));
return bundle;
diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_cert.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_cert.pem
new file mode 100644
index 000000000000..5465eb9a51b1
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIB2TCCAX+gAwIBAgIUNavUXQ/rulG+3syxU0MrxecWQGQwCgYIKoZIzj0EAwIw
+HzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwIBcNMjYxMDA5MTUxODM3
+WhgPMjEyNjA5MTUxNTE4MzdaMBQxEjAQBgNVBAMMCVRlc3QgTGVhZjBZMBMGByqG
+SM49AgEGCCqGSM49AwEHA0IABFFirHZTwx/ld55xeocgpp0rQME9O9ATEonLHecx
+RYlG8N0eMNTC4PyIVCQPfHnrI167YpwCk2nyh+HS8wl3UrOjgaEwgZ4wDAYDVR0T
+AQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwKQYD
+VR0RBCIwIIYec3BpZmZlOi8vZXhhbXBsZS50ZXN0L3dvcmtsb2FkMB0GA1UdDgQW
+BBSXEYG9sUpIGjEVcNnv5z6R/Y0UGjAfBgNVHSMEGDAWgBQtGnDllv2kiVva8/vP
+KnPkeOYblTAKBggqhkjOPQQDAgNIADBFAiBFg0vUmv7/I2KMcnIlDMpxwQhl7KUH
+fTvnJUu3TlCq3wIhAMpR49B2EVADdJTP630dUMbxQl7i0KoSinyZe+eXbGo7
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIBoTCCAUagAwIBAgIULt67iL18WCU7vF52uolgyC26bL8wCgYIKoZIzj0EAwIw
+FzEVMBMGA1UEAwwMVGVzdCBSb290IENBMCAXDTI2MTAwOTE1MTgzN1oYDzIxMjYw
+OTE1MTUxODM3WjAfMR0wGwYDVQQDDBRUZXN0IEludGVybWVkaWF0ZSBDQTBZMBMG
+ByqGSM49AgEGCCqGSM49AwEHA0IABPOMvOWtJYOV6I9Sp6C7gzA3iOi3VCz9kZQ2
+iW86iUDpk+S5jso0pP3gJy8hdJBLGkcmm9L+x4CaRcCSePsQ/vyjZjBkMBIGA1Ud
+EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBQtGnDllv2k
+iVva8/vPKnPkeOYblTAfBgNVHSMEGDAWgBT8Ay0RXQdWDWCi9PzNTwvXx+W+KzAK
+BggqhkjOPQQDAgNJADBGAiEA/K0vk+MiOpF0G0Rrpw2tpi+DtIXUlkPLSTzXVGMn
+cGgCIQChgI06W0im1y5pz3ymwTewRSF7MDlCgKt+pALGcZv1Gw==
+-----END CERTIFICATE-----
diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_key.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_key.pem
new file mode 100644
index 000000000000..37ef3f333f18
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_key.pem
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgnkUdqWqfKVO0qTH6
+WByFOh5OjqL9NOuQebS1JhEL7SChRANCAARRYqx2U8Mf5XeecXqHIKadK0DBPTvQ
+ExKJyx3nMUWJRvDdHjDUwuD8iFQkD3x56yNeu2KcApNp8ofh0vMJd1Kz
+-----END PRIVATE KEY-----