diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java index 8538f10c0d48..a279c66d0a25 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java @@ -40,9 +40,10 @@ public class DefaultMtlsProviderFactory { /** * Creates an instance of {@link MtlsProvider}. It first attempts to create an {@link - * com.google.auth.mtls.X509Provider}. If the certificate source is unavailable, it falls back to - * creating a {@link SecureConnectProvider}. If the secure connect provider also fails, it throws - * a {@link com.google.auth.mtls.CertificateSourceUnavailableException}. + * com.google.auth.mtls.X509Provider}, which also falls back to the GKE credential bundle when no + * certificate configuration is present. If the certificate source is unavailable, it falls back + * to creating a {@link SecureConnectProvider}. If the secure connect provider also fails, it + * throws a {@link com.google.auth.mtls.CertificateSourceUnavailableException}. * * @return an instance of {@link MtlsProvider}. * @throws com.google.auth.mtls.CertificateSourceUnavailableException if neither provider can be @@ -53,7 +54,7 @@ public static MtlsProvider create() throws IOException { // Note: The caller should handle CertificateSourceUnavailableException gracefully, since // it is an expected error case. All other IOExceptions are unexpected and should be surfaced // up the call stack. - MtlsProvider mtlsProvider = new X509Provider(); + MtlsProvider mtlsProvider = X509Provider.createForTransport(); if (mtlsProvider.isAvailable()) { return mtlsProvider; } 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..4d48b2e4ec7a --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java @@ -0,0 +1,151 @@ +/* + * 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.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * 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 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. + * + *

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 or unreadable later 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 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 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 that point 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 before a certificate and a private key are + * found + */ + static KeyStore createMtlsKeyStore(InputStream certAndKey) + throws GeneralSecurityException, IOException { + List certificates = new ArrayList<>(); + byte @Nullable [] privateKey = null; + PemReader reader = new PemReader(new InputStreamReader(certAndKey, StandardCharsets.UTF_8)); + while (true) { + PemReader.Section section; + try { + section = reader.readNextSection(); + } catch (IllegalArgumentException | IOException e) { + if (!certificates.isEmpty() && privateKey != null) { + // Malformed or unreadable 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())) { + certificates.add(section.getBase64DecodedBytes()); + } 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. + } + } + 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 X509Certificate parseCertificate(CertificateFactory certFactory, byte[] der) + throws CertificateException { + return (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(der)); + } +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java index d46d3822f827..b41867f4d295 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java @@ -33,6 +33,7 @@ import com.google.api.core.InternalApi; import com.google.auth.oauth2.EnvironmentProvider; import com.google.auth.oauth2.PropertyProvider; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.io.BaseEncoding; import java.io.File; @@ -58,10 +59,68 @@ public class MtlsUtils { static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; + /** + * Well-known path of the combined certificate chain and private key bundle that GKE delivers to + * pods with Agent Identity (Pod Certificates). Used as a fallback client certificate source when + * no certificate configuration file is configured or present. + */ + static final String GKE_CREDENTIAL_BUNDLE_PATH = + "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem"; + + private static volatile String gkeCredentialBundlePath = GKE_CREDENTIAL_BUNDLE_PATH; + private MtlsUtils() { // Prevent instantiation for Utility class } + /** + * Returns {@code true} if a certificate configuration is configured or present: either {@code + * GOOGLE_API_CERTIFICATE_CONFIG} is set, or the well-known gcloud {@code certificate_config.json} + * file exists. When this returns {@code true}, the GKE credential bundle fallback is not used, so + * the certificate used for mTLS and the certificate bound to tokens always come from the same + * source. + */ + public static boolean hasCertificateConfiguration( + EnvironmentProvider envProvider, PropertyProvider propProvider) { + if (!Strings.isNullOrEmpty(envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE))) { + return true; + } + try { + return getWellKnownCertificateConfigFile(envProvider, propProvider).exists(); + } catch (IOException e) { + // APPDATA missing on Windows, etc. No well-known configuration file. + return false; + } + } + + /** + * Returns the GKE credential bundle path if no certificate configuration is configured or present + * (see {@link #hasCertificateConfiguration}) and the bundle exists and is readable; otherwise + * returns {@code null}. + */ + static @Nullable String getGkeCredentialBundlePath( + EnvironmentProvider envProvider, PropertyProvider propProvider) { + if (hasCertificateConfiguration(envProvider, propProvider)) { + return null; + } + return findGkeCredentialBundle(); + } + + /** + * Returns the GKE credential bundle path if it is a readable, non-empty regular file, otherwise + * null. + */ + private static @Nullable String findGkeCredentialBundle() { + File bundle = new File(gkeCredentialBundlePath); + return bundle.isFile() && bundle.canRead() && bundle.length() > 0 ? bundle.getPath() : null; + } + + /** Overrides the GKE credential bundle path for testing; {@code null} restores the default. */ + @VisibleForTesting + static void setGkeCredentialBundlePathForTesting(@Nullable String path) { + gkeCredentialBundlePath = path != null ? path : GKE_CREDENTIAL_BUNDLE_PATH; + } + /** * Returns if mutual TLS client certificate should be used. Returns true if valid workload * certificates are configured or if GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly set to true @@ -89,16 +148,20 @@ public static boolean useMtlsClientCertificate( *

    *
  1. Non-null {@link String} (Valid happy path): A valid workload certificate * configuration was found and both the certificate and private key files exist and are - * readable. + * readable; or no certificate configuration is set or present and the GKE credential bundle + * ({@code /var/run/secrets/workload-spiffe-credentials/ + * x509.credential-bundle.private-key.pem}) exists and is readable, in which case the bundle + * path is returned. *
  2. {@link IllegalStateException} (Invalid state - fail closed): An explicit {@code * GOOGLE_API_CERTIFICATE_CONFIG} path or an existing default well-known certificate * configuration file is missing, unreadable, malformed, or references missing/unreadable * certificate or private key files. This is treated as an unrecoverable misconfiguration. *
  3. {@code null} (Safe fallback / fail open): Client certificates are explicitly * disabled via {@code GOOGLE_API_USE_CLIENT_CERTIFICATE=false}, no explicit configuration - * is set and the default well-known configuration file does not exist on disk, or the - * configuration specifies an non-workload source (e.g., ECP/PKCS11 without a {@code - * workload} section). Callers can proceed without workload certificate file polling. + * is set and neither the default well-known configuration file nor a readable GKE + * credential bundle exists on disk, or the configuration specifies an non-workload source + * (e.g., ECP/PKCS11 without a {@code workload} section). Callers can proceed without + * workload certificate file polling. *
*/ public static @Nullable String getWorkloadCertPath( @@ -175,9 +238,14 @@ public static boolean useMtlsClientCertificate( checkCertAndKeyFilesReadable(config, defaultConfigFile.getAbsolutePath(), true); return config.getCertPath(); } + // A default configuration file exists without a workload section (e.g. ECP); it takes + // precedence over the GKE credential bundle. + return null; } - return null; + // 3. GKE credential bundle (Pod Certificates), used only when no certificate configuration + // file is configured or present. The combined bundle serves as both certificate and key. + return findGkeCredentialBundle(); } private static void checkCertAndKeyFilesReadable( 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..0930bfebaf3d 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,7 +32,6 @@ 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; @@ -148,7 +147,7 @@ static KeyStore getKeyStore(InputStream metadata, ProcessProvider processProvide } // Create mTLS key store with the input certificates from shell command. - return SecurityUtils.createMtlsKeyStore(process.getInputStream()); + return MtlsKeyStoreUtils.createMtlsKeyStore(process.getInputStream()); } @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 911c0fdcf922..f88cbe1b996c 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; @@ -38,6 +37,7 @@ import com.google.auth.oauth2.SystemPropertyProvider; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.SequenceInputStream; @@ -54,7 +54,8 @@ public class X509Provider implements MtlsProvider { private final EnvironmentProvider envProvider; private final PropertyProvider propProvider; - private final String certConfigPathOverride; + private final @Nullable String certConfigPathOverride; + private final boolean allowGkeCredentialBundle; /** * Creates an X509 provider with an override path for the certificate configuration, bypassing the @@ -70,9 +71,39 @@ public X509Provider( EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride) { + this(envProvider, propProvider, certConfigPathOverride, false); + } + + /** + * Creates an X509 provider that optionally falls back to the GKE credential bundle when no + * certificate configuration is present. + * + * @param envProvider environment provider used for environment variables + * @param propProvider property provider used for system properties + * @param certConfigPathOverride the path to read the certificate configuration from. + * @param allowGkeCredentialBundle whether to fall back to the GKE credential bundle when no + * certificate configuration is present and {@code certConfigPathOverride} is null. + */ + X509Provider( + EnvironmentProvider envProvider, + PropertyProvider propProvider, + @Nullable String certConfigPathOverride, + boolean allowGkeCredentialBundle) { this.envProvider = envProvider; this.propProvider = propProvider; this.certConfigPathOverride = certConfigPathOverride; + this.allowGkeCredentialBundle = allowGkeCredentialBundle; + } + + /** + * Creates an X.509 provider for the client transport layer. In addition to the locations checked + * by {@link #X509Provider()}, it falls back to the GKE credential bundle when no certificate + * configuration is present, so that the transport uses the same certificate that {@link + * MtlsUtils#getWorkloadCertPath} reports for certificate-bound tokens. + */ + static X509Provider createForTransport() { + return new X509Provider( + SystemEnvironmentProvider.getInstance(), SystemPropertyProvider.getInstance(), null, true); } /** @@ -105,15 +136,37 @@ public X509Provider() { *
  • The certificate config override path, if set. *
  • The path pointed to by the "GOOGLE_API_CERTIFICATE_CONFIG" environment variable *
  • The well known gcloud location for the certificate configuration file. + *
  • If none of the above is configured or present, and this provider was created with {@link + * #createForTransport()}, the GKE credential bundle at {@code + * /var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem}, + * 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 */ @Override public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException { + if (allowGkeCredentialBundle && certConfigPathOverride == null) { + String gkeCredentialBundlePath = + MtlsUtils.getGkeCredentialBundlePath(envProvider, propProvider); + if (gkeCredentialBundlePath != null) { + try (InputStream bundleStream = new FileInputStream(new File(gkeCredentialBundlePath))) { + return MtlsKeyStoreUtils.createMtlsKeyStore(bundleStream); + } catch (FileNotFoundException e) { + // The bundle was removed (e.g. mid-rotation) after the availability check. + throw new CertificateSourceUnavailableException( + "GKE credential bundle is no longer available: " + gkeCredentialBundlePath, e); + } catch (Exception e) { + throw new IOException( + "X509Provider: Unable to load GKE credential bundle: " + gkeCredentialBundlePath, e); + } + } + } + WorkloadCertificateConfiguration workloadCertConfig = MtlsUtils.getWorkloadCertificateConfiguration( envProvider, propProvider, certConfigPathOverride); @@ -126,7 +179,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/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 21f55a527e88..4275782c80f4 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -32,6 +32,7 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; +import com.google.auth.mtls.MtlsUtils; import com.google.auth.oauth2.AgentIdentityCacheUtils.CachedAgentIdentityInfo; import com.google.auth.oauth2.AgentIdentityCacheUtils.FileMetadata; import com.google.auth.oauth2.AgentIdentityCertificateValidationUtils.InvalidCertificateException; @@ -92,6 +93,20 @@ final class AgentIdentityUtils { private static volatile String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + /** + * File name of the combined certificate chain and private key bundle that GKE delivers to the + * well-known directory for pods with Agent Identity (Pod Certificates). + * + *

    When {@link #GOOGLE_API_CERTIFICATE_CONFIG} is unset and no well-known gcloud certificate + * configuration file exists, the presence of this bundle establishes mTLS intent on its own (no + * {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} required). It is never polled for, because GKE + * delivers it before the container starts and polling on its absence would delay every workload + * without Agent Identity. It must be a readable, non-empty file. When present and allowed, it + * takes precedence over the other well-known files, matching the certificate the transport + * presents. + */ + static final String GKE_CREDENTIAL_BUNDLE_FILE = "x509.credential-bundle.private-key.pem"; + // Retries for verifying certificate and private key matching during atomic key rotation. private static final int CERT_KEY_MATCH_RETRIES = 3; @@ -117,6 +132,8 @@ final class AgentIdentityUtils { private static volatile EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance(); + private static volatile PropertyProvider propertyProvider = SystemPropertyProvider.getInstance(); + private static volatile TimeService timeService = Thread::sleep; // Tracks whether initial container startup discovery has completed. The 30-second @@ -281,6 +298,49 @@ private static boolean isPathInWellKnownDir(final @Nullable String pathStr) { } } + /** Returns the path of the GKE credential bundle in the well-known directory. */ + private static String getGkeCredentialBundlePath() { + return Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString(); + } + + /** Returns {@code true} if {@code path} is the GKE credential bundle in the well-known dir. */ + private static boolean isGkeCredentialBundle(final @Nullable String path) { + return path != null && getGkeCredentialBundlePath().equals(path); + } + + /** + * Returns {@code true} if the GKE credential bundle is a readable, non-empty regular file. + * Mirrors the readiness check the transport uses (see {@link MtlsUtils#getWorkloadCertPath}). + */ + private static boolean isGkeCredentialBundleReady(final String path) { + Path bundle = Paths.get(path); + try { + return Files.isRegularFile(bundle) && Files.isReadable(bundle) && Files.size(bundle) > 0; + } catch (IOException e) { + return false; + } + } + + /** + * Returns {@code true} if the GKE credential bundle may be used: no well-known gcloud certificate + * configuration file exists (it would otherwise be the transport's mTLS certificate source, see + * {@link MtlsUtils#hasCertificateConfiguration}). Callers only reach this when {@link + * #GOOGLE_API_CERTIFICATE_CONFIG} is unset. + */ + private static boolean isGkeCredentialBundleAllowed() { + return !MtlsUtils.hasCertificateConfiguration(environmentProvider, propertyProvider); + } + + /** + * Returns whether mTLS intent is established for resolved certificate paths: either they came + * from a workload certificate config, or they point at the GKE credential bundle (whose presence + * establishes intent on its own). + */ + private static boolean isMtlsIntentEstablished( + final boolean hasWorkloadConfig, final @Nullable String certPath) { + return hasWorkloadConfig || isGkeCredentialBundle(certPath); + } + /** * Retrieves the certificate and raw PEM content for the Agent Identity. * @@ -319,14 +379,16 @@ private static boolean isPathInWellKnownDir(final @Nullable String pathStr) { // isCachedInfoValid() verified that the cached certificate file still exists on disk with // matching metadata, so certsPresent is guaranteed to be true here. if (!shouldEnableMtls( - /* certsPresent= */ true, /* configExists= */ initialCached.configMetadata != null)) { + /* certsPresent= */ true, + /* configExists= */ isMtlsIntentEstablished( + initialCached.configMetadata != null, initialCached.certMetadata.getPath()))) { return null; } return initialCached.certInfo; } ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath, initialCached); - boolean configExists = paths.hasWorkloadConfig(); + boolean configExists = isMtlsIntentEstablished(paths.hasWorkloadConfig(), paths.getCertPath()); boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath()); if (!shouldEnableMtls(certsPresent, configExists)) { @@ -654,9 +716,23 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( final @Nullable CachedAgentIdentityInfo initialCached) throws IOException { if (!isMtlsExplicitlyEnabled()) { // Without a config file (configExists == false), mTLS is only enabled when - // GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly "true". + // GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly "true", or, when it is unset, implicitly by + // the presence of the GKE credential bundle. initialStartupCompleted = true; - return new ResolvedCertAndKeyPaths(null, null, false); + if (isMtlsExplicitlyDisabled()) { + return new ResolvedCertAndKeyPaths(null, null, false); + } + return getGkeCredentialBundlePathIfPresent(initialCached); + } + + // The GKE bundle is checked once, without polling, and before the other well-known files: when + // allowed, it is the certificate the transport presents for mTLS (see + // MtlsUtils#getWorkloadCertPath). + String gkeBundlePath = getGkeCredentialBundlePath(); + if (isGkeCredentialBundleAllowed() && isGkeCredentialBundleReady(gkeBundlePath)) { + initialStartupCompleted = true; + // GKE combined bundle file contains both certificate chain and private key + return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false); } String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); @@ -717,7 +793,11 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( if (fallbackCached != null && fallbackCached.configMetadata == null && fallbackCached.certMetadata != null - && fallbackCached.keyMetadata != null) { + && fallbackCached.keyMetadata != null + // A cached GKE credential is only reused while the GKE bundle is still allowed (no + // certificate configuration exists), matching the certificate the transport presents. + && (!isGkeCredentialBundle(fallbackCached.certMetadata.getPath()) + || isGkeCredentialBundleAllowed())) { return new ResolvedCertAndKeyPaths( fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false); } @@ -730,6 +810,42 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( wellKnownDir, GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN)); } + /** + * Implicit GKE discovery used when {@link #GOOGLE_API_CERTIFICATE_CONFIG} and {@link + * #GOOGLE_API_USE_CLIENT_CERTIFICATE} are both unset: returns the GKE credential bundle as both + * certificate and key if it is a readable, non-empty file and no well-known gcloud certificate + * configuration file exists. Checked once without polling (see {@link + * #GKE_CREDENTIAL_BUNDLE_FILE}). + * + *

    The readability and precedence rules mirror {@link MtlsUtils#getWorkloadCertPath}, so tokens + * are only bound to the GKE certificate when the transport also uses it for mTLS. If the bundle + * is transiently unavailable (e.g. mid-rotation) after a GKE credential was cached, the cached + * paths are returned so {@link #loadAndVerifyCredentials} can fall back to the cached credential. + */ + private static ResolvedCertAndKeyPaths getGkeCredentialBundlePathIfPresent( + final @Nullable CachedAgentIdentityInfo initialCached) { + // Checked first so that neither a fresh nor a cached GKE credential is used once a certificate + // configuration exists (the transport would then present that configuration's certificate). + if (!isGkeCredentialBundleAllowed()) { + return new ResolvedCertAndKeyPaths(null, null, false); + } + String gkeBundlePath = getGkeCredentialBundlePath(); + if (isGkeCredentialBundleReady(gkeBundlePath)) { + return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false); + } + CachedAgentIdentityInfo fallbackCached = + AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached); + if (fallbackCached != null + && fallbackCached.configMetadata == null + && fallbackCached.certMetadata != null + && fallbackCached.keyMetadata != null + && isGkeCredentialBundle(fallbackCached.certMetadata.getPath())) { + return new ResolvedCertAndKeyPaths( + fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false); + } + return new ResolvedCertAndKeyPaths(null, null, false); + } + /** * Determines if mTLS should be enabled based on environment variables and certificate presence. * @@ -738,6 +854,9 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( * workload config exists ({@code configExists == true}), whereas when {@code * GOOGLE_API_USE_CLIENT_CERTIFICATE="true"} (Case 1), mTLS is enabled whenever {@code * certsPresent == true} even from the well-known directory ({@code configExists == false}). + * + *

    Callers pass {@code configExists == true} for the GKE credential bundle as well (see {@link + * #GKE_CREDENTIAL_BUNDLE_FILE}), since its presence establishes mTLS intent on its own. */ static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists) throws IOException { @@ -837,6 +956,7 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certC static void resetForTest() { wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; environmentProvider = SystemEnvironmentProvider.getInstance(); + propertyProvider = SystemPropertyProvider.getInstance(); timeService = Thread::sleep; resetCachedState(); } @@ -867,6 +987,16 @@ static void setEnvironmentProvider(final EnvironmentProvider provider) { resetCachedState(); } + /** + * Sets the system property provider for testing. Used to resolve the well-known gcloud + * certificate configuration file location. + */ + @VisibleForTesting + static void setPropertyProvider(final PropertyProvider provider) { + propertyProvider = provider; + resetCachedState(); + } + /** Sets the time and sleep service for testing. */ @VisibleForTesting static void setTimeService(final TimeService service) { 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..935d1ee8bcc8 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java @@ -0,0 +1,259 @@ +/* + * 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 java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.GeneralSecurityException; +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.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 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 = + com.google.api.client.util.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 = + com.google.api.client.util.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 = com.google.api.client.util.SecurityUtils.createMtlsKeyStore(stream(input)); + + assertEquals("EC", keyStore.getKey(onlyAlias(keyStore), new char[] {}).getAlgorithm()); + assertEquals("EC", upstream.getKey(onlyAlias(upstream), new char[] {}).getAlgorithm()); + 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( + GeneralSecurityException.class, () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(input))); + assertThrows( + GeneralSecurityException.class, + () -> com.google.api.client.util.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 = com.google.api.client.util.SecurityUtils.createMtlsKeyStore(stream(input)); + + assertEquals("EC", keyStore.getKey(onlyAlias(keyStore), new char[] {}).getAlgorithm()); + assertEquals("EC", upstream.getKey(onlyAlias(upstream), new char[] {}).getAlgorithm()); + 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. + KeyStore keyStore = + MtlsKeyStoreUtils.createMtlsKeyStore( + stream( + read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH), "-----BEGIN CERTIFICATE-----\nAAAA")); + + assertEquals(2, keyStore.getCertificateChain(onlyAlias(keyStore)).length); + } + + @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-----"; + KeyStore keyStore = + MtlsKeyStoreUtils.createMtlsKeyStore( + stream(leafAndIntermediate[0], bad, 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 String subject(Certificate certificate) { + return ((X509Certificate) certificate).getSubjectX500Principal().getName(); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java index d5fdc840d804..546dfbd2c220 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java @@ -39,6 +39,8 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -46,6 +48,18 @@ class MtlsUtilsTest { @TempDir Path tempDir; + @BeforeEach + void setUp() { + // Never consult the real GKE well-known path from tests. + MtlsUtils.setGkeCredentialBundlePathForTesting( + tempDir.resolve("no-gke-bundle").resolve("bundle.pem").toString()); + } + + @AfterEach + void tearDown() { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + @Test void getCertificatePath_succeeds() throws IOException { Path configFile = tempDir.resolve("config.json"); @@ -738,4 +752,124 @@ void getCertificateFingerprint_invalidOrNull_returnsNull() { assertNull(MtlsUtils.getCertificateFingerprint("/nonexistent/file.crt")); assertNull(MtlsUtils.getCertificateFingerprint(tempDir.toString())); // Directory } + + // --- GKE Credential Bundle Tests --- + + private PropertyProvider homeInTempDir() { + return (name, def) -> { + if ("user.home".equals(name)) return tempDir.resolve("home").toString(); + if ("os.name".equals(name)) return "Linux"; + return def; + }; + } + + private Path writeGkeBundle() throws IOException { + Path bundle = tempDir.resolve("x509.credential-bundle.private-key.pem"); + Files.write(bundle, "bundle".getBytes()); + MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString()); + return bundle; + } + + private void writeDefaultGcloudConfig(String content) throws IOException { + Path gcloudDir = tempDir.resolve("home").resolve(".config").resolve("gcloud"); + Files.createDirectories(gcloudDir); + Files.write(gcloudDir.resolve("certificate_config.json"), content.getBytes()); + } + + @Test + void getWorkloadCertPath_noConfig_gkeBundlePresent_returnsBundlePath() throws IOException { + Path bundle = writeGkeBundle(); + EnvironmentProvider envProvider = name -> null; + + assertEquals(bundle.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertTrue(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); + } + + @Test + void getWorkloadCertPath_noConfig_gkeBundleAbsent_returnsNull() { + MtlsUtils.setGkeCredentialBundlePathForTesting(tempDir.resolve("missing.pem").toString()); + EnvironmentProvider envProvider = name -> null; + + assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); + } + + @Test + void getWorkloadCertPath_gkeBundleIsDirectory_returnsNull() throws IOException { + Path dir = Files.createDirectory(tempDir.resolve("x509.credential-bundle.private-key.pem")); + MtlsUtils.setGkeCredentialBundlePathForTesting(dir.toString()); + + assertNull(MtlsUtils.getWorkloadCertPath(name -> null, homeInTempDir())); + } + + @Test + void getWorkloadCertPath_gkeBundleEmpty_returnsNull() throws IOException { + Path bundle = Files.createFile(tempDir.resolve("x509.credential-bundle.private-key.pem")); + MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString()); + EnvironmentProvider envProvider = name -> null; + + assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); + assertNull(MtlsUtils.getGkeCredentialBundlePath(envProvider, homeInTempDir())); + } + + @Test + void getWorkloadCertPath_useClientCertFalse_ignoresGkeBundle() throws IOException { + writeGkeBundle(); + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name) ? "false" : null; + + assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); + } + + @Test + void getWorkloadCertPath_defaultConfigWithoutWorkload_ignoresGkeBundle() throws IOException { + writeGkeBundle(); + writeDefaultGcloudConfig("{\"cert_configs\":{\"enterprise_certificates\":{\"libs\":[]}}}"); + + assertNull(MtlsUtils.getWorkloadCertPath(name -> null, homeInTempDir())); + } + + @Test + void getWorkloadCertPath_explicitConfig_takesPrecedenceOverGkeBundle() throws IOException { + writeGkeBundle(); + Path certFile = Files.write(tempDir.resolve("cert.pem"), "cert".getBytes()); + Path keyFile = Files.write(tempDir.resolve("key.pem"), "key".getBytes()); + Path configFile = tempDir.resolve("config.json"); + Files.write( + configFile, + ("{\"cert_configs\":{\"workload\":{\"cert_path\":\"" + + certFile + + "\",\"key_path\":\"" + + keyFile + + "\"}}}") + .getBytes()); + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + + assertEquals(certFile.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + } + + @Test + void hasCertificateConfiguration_reflectsExplicitEnvAndDefaultFile() throws IOException { + assertFalse(MtlsUtils.hasCertificateConfiguration(name -> null, homeInTempDir())); + assertTrue( + MtlsUtils.hasCertificateConfiguration( + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? "/any/path.json" : null, + homeInTempDir())); + + writeDefaultGcloudConfig("{}"); + assertTrue(MtlsUtils.hasCertificateConfiguration(name -> null, homeInTempDir())); + } + + @Test + void getGkeCredentialBundlePath_onlyWhenNoConfiguration() throws IOException { + Path bundle = writeGkeBundle(); + assertEquals( + bundle.toString(), MtlsUtils.getGkeCredentialBundlePath(name -> null, homeInTempDir())); + + writeDefaultGcloudConfig("{}"); + assertNull(MtlsUtils.getGkeCredentialBundlePath(name -> null, homeInTempDir())); + } } 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..0e56da9c7172 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 java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +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,23 @@ public Process createProcess(InputStream metadata) throws IOException { } } + @Test + void testGetKeyStore_certificateChain_keepsFullChain() throws Exception { + byte[] stdout = + (MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH) + + "\n" + + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH)) + .getBytes(StandardCharsets.UTF_8); + SecureConnectProvider.ProcessProvider processProvider = + metadata -> new TestCertProviderCommandProcess(0, false, new ByteArrayInputStream(stdout)); + + KeyStore store = + SecureConnectProvider.getKeyStore( + new ByteArrayInputStream("{}".getBytes(StandardCharsets.UTF_8)), 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 5ddd1a169d29..03674501cac4 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 @@ -31,7 +31,9 @@ package com.google.auth.mtls; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -50,10 +52,12 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class X509ProviderTest { private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem"; + private static final String TEST_KEY_PATH = "testresources/mtls/test_key.pem"; private static final String TEST_CONFIG_PATH = "testresources/mtls/certificate_config.json"; @Test @@ -208,4 +212,199 @@ void x509Provider_malformedCert_throws() throws IOException { assertThrows(Exception.class, testProvider::getKeyStore); } + + @Test + void x509Provider_noConfig_usesGkeCredentialBundle(@TempDir Path tempDir) throws Exception { + Path bundle = writeGkeBundle(tempDir, /* keyFirst= */ false); + MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString()); + try { + X509Provider testProvider = + new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true); + + KeyStore store = testProvider.getKeyStore(); + assertEquals(1, store.size()); + assertNotNull(store.getCertificateAlias(loadTestCert())); + assertTrue(testProvider.isAvailable()); + } finally { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + } + + @Test + void x509Provider_noConfig_usesGkeCredentialBundleWithKeyFirst(@TempDir Path tempDir) + throws Exception { + Path bundle = writeGkeBundle(tempDir, /* keyFirst= */ true); + MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString()); + try { + X509Provider testProvider = + new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true); + + KeyStore store = testProvider.getKeyStore(); + assertNotNull(store.getCertificateAlias(loadTestCert())); + } finally { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + } + + @Test + void x509Provider_gkeCredentialBundleWithIntermediate_keepsFullChain(@TempDir Path tempDir) + throws Exception { + Path bundle = tempDir.resolve("x509.credential-bundle.private-key.pem"); + Files.write( + bundle, + (MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH) + + "\n" + + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH)) + .getBytes(UTF_8)); + 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 = tempDir.resolve("x509.credential-bundle.private-key.pem"); + Files.write( + bundle, + (MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH) + + "\n" + + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH)) + .getBytes(UTF_8)); + 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\":\"" + + new File(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH).getAbsolutePath() + + "\",\"key_path\":\"" + + new File(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH).getAbsolutePath() + + "\"}}}") + .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 { + MtlsUtils.setGkeCredentialBundlePathForTesting( + tempDir.resolve("does_not_matter.pem").toString()); + try { + TestEnvironmentProvider envProvider = new TestEnvironmentProvider(); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "badfile.txt"); + // Even with a bundle present, an explicit config is used (and fails here). + writeGkeBundleAt(tempDir.resolve("does_not_matter.pem"), false); + X509Provider testProvider = + new X509Provider(envProvider, noGcloudConfig(tempDir), null, true); + + CertificateSourceUnavailableException e = + assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore); + assertFalse(String.valueOf(e.getMessage()).contains("GKE")); + } finally { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + } + + @Test + void x509Provider_overridePath_ignoresGkeCredentialBundle(@TempDir Path tempDir) + throws Exception { + Path bundle = writeGkeBundle(tempDir, false); + MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString()); + try { + X509Provider testProvider = + new X509Provider( + new TestEnvironmentProvider(), noGcloudConfig(tempDir), "badfile.txt", true); + + CertificateSourceUnavailableException e = + assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore); + assertFalse(String.valueOf(e.getMessage()).contains("GKE")); + } finally { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + } + + @Test + void x509Provider_noConfigAndNoGkeCredentialBundle_isUnavailable(@TempDir Path tempDir) + throws Exception { + MtlsUtils.setGkeCredentialBundlePathForTesting(tempDir.resolve("missing.pem").toString()); + try { + X509Provider testProvider = + new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true); + + assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore); + assertFalse(testProvider.isAvailable()); + } finally { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + } + + @Test + void x509Provider_publicConstructor_ignoresGkeCredentialBundle(@TempDir Path tempDir) + throws Exception { + Path bundle = writeGkeBundle(tempDir, false); + MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString()); + try { + // Non-transport callers (e.g. workload identity federation X.509 credentials) keep the + // existing certificate configuration lookup and never fall back to the GKE bundle. + X509Provider testProvider = + new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null); + + assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore); + assertFalse(testProvider.isAvailable()); + } finally { + MtlsUtils.setGkeCredentialBundlePathForTesting(null); + } + } + + /** Property provider whose user.home has no gcloud certificate_config.json. */ + private static TestPropertyProvider noGcloudConfig(Path tempDir) { + TestPropertyProvider propProvider = new TestPropertyProvider(); + propProvider.setProperty("os.name", "Linux"); + propProvider.setProperty("user.home", tempDir.resolve("home").toString()); + return propProvider; + } + + private static Certificate loadTestCert() throws IOException, CertificateException { + try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) { + return CertificateFactory.getInstance("X.509").generateCertificate(fis); + } + } + + private static Path writeGkeBundle(Path dir, boolean keyFirst) throws IOException { + return writeGkeBundleAt(dir.resolve("x509.credential-bundle.private-key.pem"), 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); + 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/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index e9302576435f..d6d25407a394 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -71,6 +71,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -95,6 +96,7 @@ class AgentIdentityUtilsTest { "spiffe://agents.global.org-INVALID.system.id.goog/path"; private TestEnvironmentProvider envProvider; + private TestPropertyProvider propertyProvider; @TempDir private Path tempDir; @BeforeEach @@ -102,6 +104,12 @@ void setUp() throws IOException { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvironmentProvider(envProvider); AgentIdentityUtils.setTimeService(new FakeTimeService()); + // Resolve the well-known gcloud certificate_config.json under a temp home, so a real config on + // the machine running the tests cannot affect GKE credential bundle discovery. + propertyProvider = new TestPropertyProvider(); + propertyProvider.setProperty("os.name", "Linux"); + propertyProvider.setProperty("user.home", tempDir.resolve("home").toString()); + AgentIdentityUtils.setPropertyProvider(propertyProvider); } @AfterEach @@ -1808,6 +1816,221 @@ public void verifyKeyPair_unsupportedKeyAlgorithm_throwsUnsupportedKeyAlgorithmE assertTrue(e.getMessage().contains("Unsupported key algorithm: DSA")); } + // --- GKE credential bundle (x509.credential-bundle.private-key.pem) --- + + private Path writeGkeBundleInWellKnownDir(String certResource, String keyResource) + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + String certPem = + new String( + Files.readAllBytes(Paths.get(resourcePath(certResource))), StandardCharsets.UTF_8); + String keyPem = + new String( + Files.readAllBytes(Paths.get(resourcePath(keyResource))), StandardCharsets.UTF_8); + Path bundle = tempDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE); + Files.write(bundle, (certPem + "\n" + keyPem).getBytes(StandardCharsets.UTF_8)); + return bundle; + } + + private Path writeAgentGkeBundle() throws Exception { + return writeGkeBundleInWellKnownDir( + "agent/agent_spiffe_cert.pem", "agent/agent_spiffe_key.pem"); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_implicit_returnsCertWithoutPolling() + throws Exception { + writeAgentGkeBundle(); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + + assertNotNull(info); + assertTrue( + AgentIdentityCertificateValidationUtils.shouldRequestBoundToken(info.getCertificate())); + assertTrue(info.getCertContent().contains("BEGIN CERTIFICATE")); + assertFalse(info.getCertContent().contains("PRIVATE KEY")); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_secondCallUsesCacheAndStaysBound() + throws Exception { + writeAgentGkeBundle(); + + AgentIdentityUtils.CertInfo first = AgentIdentityUtils.getAgentIdentityCertInfo(); + AgentIdentityUtils.CertInfo second = AgentIdentityUtils.getAgentIdentityCertInfo(); + + assertNotNull(first); + assertSame(first, second); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_useClientCertFalse_returnsNull() throws Exception { + writeAgentGkeBundle(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "false"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_enableRuntimeBoundTokenFalse_returnsNull() + throws Exception { + writeAgentGkeBundle(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_defaultGcloudConfigExists_returnsNull() + throws Exception { + writeAgentGkeBundle(); + Path gcloudDir = tempDir.resolve("home").resolve(".config").resolve("gcloud"); + Files.createDirectories(gcloudDir); + Files.write(gcloudDir.resolve("certificate_config.json"), "{}".getBytes()); + + // The gcloud config is the transport's mTLS certificate source, so tokens must not be bound to + // the GKE certificate. + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_nonAgentCertificate_returnsNull() + throws Exception { + writeGkeBundleInWellKnownDir("mtls/test_cert.pem", "mtls/test_key.pem"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundleAbsent_returnsNullWithoutPolling() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_useClientCertTrue_isDiscovered() throws Exception { + writeAgentGkeBundle(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundleEmpty_returnsNullWithoutPolling() throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + Files.createFile(tempDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE)); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_useClientCertTrue_isNotPolledFor() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true"); + FakeTimeService fakeTime = new FakeTimeService(); + AtomicBoolean written = new AtomicBoolean(false); + fakeTime.setOnSleepCallback( + () -> { + if (written.compareAndSet(false, true)) { + try { + writeAgentGkeBundle(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + AgentIdentityUtils.setTimeService(fakeTime); + + // The bundle appears during startup polling, but only the Cloud Run files are polled for. + assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue(written.get()); + + // After startup, the bundle is found by the single up-front check. + assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundle_takesPrecedenceOverCloudRunFilesWhenExplicit() + throws Exception { + // The transport presents the GKE bundle when no certificate config exists, so the token must be + // bound to it even if other well-known files are present. + writeAgentGkeBundle(); + copyResource("mtls/test_cert.pem", tempDir.resolve("certificates.pem")); + copyResource("mtls/test_key.pem", tempDir.resolve("private_key.pem")); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true"); + + // The GKE bundle (agent cert) wins over certificates.pem/private_key.pem (non-agent cert). + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_fallsBackToCachedCredential() + throws Exception { + Path bundle = writeAgentGkeBundle(); + AgentIdentityUtils.CertInfo first = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(first); + + // Simulate a transient gap during rotation. + Files.delete(bundle); + + assertSame(first, AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + private void writeDefaultGcloudCertificateConfig() throws Exception { + Path gcloudDir = tempDir.resolve("home").resolve(".config").resolve("gcloud"); + Files.createDirectories(gcloudDir); + Files.write(gcloudDir.resolve("certificate_config.json"), "{}".getBytes()); + } + + @Test + public void getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_configAppears_doesNotUseCache() + throws Exception { + Path bundle = writeAgentGkeBundle(); + assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + + // The bundle is transiently missing and a certificate config now exists, so the transport + // presents the config's certificate; the cached GKE credential must not be reused. + Files.delete(bundle); + writeDefaultGcloudCertificateConfig(); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void + getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_configAppears_explicit_doesNotUseCache() + throws Exception { + Path bundle = writeAgentGkeBundle(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + + Files.delete(bundle); + writeDefaultGcloudCertificateConfig(); + + // No other well-known files exist and the cached GKE credential is not allowed anymore. + assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertEquals(0, fakeTime.getSleepCount()); + } + private String resourcePath(String resource) throws Exception { URL url = getClass().getClassLoader().getResource(resource); assertNotNull(url, "Test resource " + resource + " not found"); 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-----