This method attempts to load the certificate and private key for the agent identity. It + * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment + * variable. If not set, it falls back to well-known default locations. + * + *
To handle transient race conditions during certificate rotation on disk, this method employs
+ * a retry mechanism with backoff when reading the configuration and certificate files.
+ *
+ * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code
+ * null} if the agent identity features are disabled, opted out, or if no valid credentials
+ * could be loaded.
+ * @throws IOException If an I/O error occurs while reading the files, or if the key-pair
+ * verification fails after retries.
+ */
+ static CertInfo getAgentIdentityCertInfo() throws IOException {
+ if (!isTokenBindingEnabled()) {
+ return null;
+ }
+ String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG);
+ boolean configExists =
+ !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath));
+
+ ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath);
+ boolean certsPresent = !Strings.isNullOrEmpty(paths.certPath);
+
+ if (!shouldEnableMtls(certsPresent, configExists)) {
+ return null;
+ }
+
+ return loadAndVerifyCredentials(paths.certPath, paths.keyPath);
+ }
+
+ /**
+ * Resolves the paths for the certificate and private key based on the config path or well-known
+ * locations.
+ */
+ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException {
+ String certPath = null;
+ String keyPath = null;
+
+ if (!Strings.isNullOrEmpty(certConfigPath)) {
+ java.nio.file.Path configPath = Paths.get(certConfigPath);
+ if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) {
+ // Fail-fast if config doesn't exist and we are not in a workload environment
+ return new ResolvedCertAndKeyPaths(null, null);
+ }
+ // Read cert and key paths from config file. We use retry with backoff to handle transient
+ // race conditions where the config file might be being updated by a rotation process.
+ ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath);
+ if (paths != null) {
+ certPath = paths.certPath;
+ keyPath = paths.keyPath;
+ }
+ } else {
+ if (!Files.exists(Paths.get(wellKnownDir))) {
+ // Fail-fast if well-known dir doesn't exist (e.g. workstation)
+ return new ResolvedCertAndKeyPaths(null, null);
+ }
+ // Fallback to well-known locations. We use retry with backoff here as well to handle
+ // race conditions during file replacement by a rotation process.
+ certPath = getWellKnownCertificatePathWithRetry();
+ if (certPath != null) {
+ if (certPath.endsWith("credentialbundle.pem")) {
+ keyPath = certPath; // Bundle contains both
+ } else if (certPath.endsWith("certificates.pem")) {
+ keyPath = Paths.get(wellKnownDir, "private_key.pem").toString();
+ }
+ }
+ }
+ return new ResolvedCertAndKeyPaths(certPath, keyPath);
+ }
+
+ /**
+ * Loads the certificate and private key, and verifies that they match if they are separate files.
+ */
+ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException {
+ X509Certificate cert = null;
+ PrivateKey privateKey = null;
+ String certContent = null;
+
+ if (!Strings.isNullOrEmpty(certPath)
+ && !Strings.isNullOrEmpty(keyPath)
+ && !certPath.equals(keyPath)
+ && Files.exists(Paths.get(keyPath))) {
+ // Separate files, verify match with retry
+ int retries = 0;
+ boolean matched = false;
+ while (retries < CERT_KEY_MATCH_RETRIES) {
+ try {
+ certContent = readCertificateChain(certPath);
+ cert = parseCertificateContent(certContent);
+ privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm());
+
+ if (verifyKeyPair(cert, privateKey)) {
+ matched = true;
+ break;
+ }
+ LOGGER.warn("Cert and key mismatch, retrying...");
+ } catch (java.nio.file.AccessDeniedException e) {
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ "Permission denied reading certificate or key files. Falling back to unbound token.");
+ return null;
+ } catch (Exception e) {
+ LOGGER.warn("Failed to read or verify cert/key, retrying...", e);
+ }
+
+ retries++;
+ if (retries < CERT_KEY_MATCH_RETRIES) {
+ try {
+ timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting for cert/key match.", e);
+ }
+ }
+ }
+
+ if (!matched) {
+ throw new IOException(
+ String.format(
+ "Agent Identity certificate and private key mismatch or read failure after %d retries.",
+ CERT_KEY_MATCH_RETRIES));
+ }
+ } else if (!Strings.isNullOrEmpty(certPath)) {
+ // Bundle or only cert available
+ try {
+ certContent = readCertificateChain(certPath);
+ cert = parseCertificateContent(certContent);
+ } catch (java.nio.file.AccessDeniedException e) {
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ "Permission denied reading certificate files. Falling back to unbound token.");
+ return null;
+ }
+ }
+
+ return new CertInfo(cert, certContent);
+ }
+
+ /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */
+ private static boolean checkExistsOrAccessDenied(java.nio.file.Path path)
+ throws java.nio.file.AccessDeniedException {
+ try {
+ Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class);
+ return true;
+ } catch (java.nio.file.AccessDeniedException e) {
+ throw e;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /** Checks if the user has disabled token binding by setting the environment variable to false. */
+ private static boolean isTokenBindingEnabled() {
+ String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES);
+ return !("false".equalsIgnoreCase(preventSharing));
+ }
+
+ /**
+ * Reads the certificate path from the config file with retry logic to handle rotation race
+ * conditions.
+ */
+ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath)
+ throws IOException {
+ boolean warned = false;
+ for (long sleepInterval : POLLING_INTERVALS) {
+ try {
+ if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) {
+ ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath);
+ if (paths != null
+ && !Strings.isNullOrEmpty(paths.certPath)
+ && checkExistsOrAccessDenied(Paths.get(paths.certPath))) {
+ return paths;
+ }
+ }
+ } catch (java.nio.file.AccessDeniedException e) {
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ "Permission denied reading certificate config file. Falling back to unbound token.");
+ return null;
+ } catch (IOException e) {
+ // Fall through to retry
+ }
+ if (!warned) {
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ String.format(
+ "Certificate config file not found or invalid at %s (from %s environment variable)."
+ + " Retrying for up to %d seconds.",
+ certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000));
+ warned = true;
+ }
+ try {
+ timeService.sleep(sleepInterval);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(
+ "Interrupted while waiting for Agent Identity certificate files for bound token"
+ + " request.",
+ e);
+ }
+ }
+ throw new IOException(
+ "Unable to find Agent Identity certificate config or file for bound token request after"
+ + " multiple retries. Token binding protection is failing. You can turn off this"
+ + " protection by setting "
+ + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES
+ + " to false to fall back to unbound tokens.");
+ }
+
+ /** Searches for certificates at well-known locations with retry logic. */
+ private static String getWellKnownCertificatePathWithRetry() throws IOException {
+ String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString();
+ String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString();
+
+ boolean warned = false;
+ for (long sleepInterval : POLLING_INTERVALS) {
+ try {
+ if (checkExistsOrAccessDenied(Paths.get(bundlePath))) {
+ return bundlePath;
+ }
+ if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) {
+ return certOnlyPath;
+ }
+ } catch (java.nio.file.AccessDeniedException e) {
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ "Permission denied reading well-known certificates. Falling back to unbound token.");
+ return null;
+ } catch (Exception e) {
+ // Fall through to retry
+ }
+ if (!warned) {
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ String.format(
+ "Well-known certificate file not found at %s. Retrying for up to %d seconds.",
+ wellKnownDir, TOTAL_TIMEOUT_MS / 1000));
+ warned = true;
+ }
+ try {
+ timeService.sleep(sleepInterval);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting for well-known certificate files.", e);
+ }
+ }
+ throw new IOException(
+ "Unable to find well-known certificate file for bound token request after multiple"
+ + " retries.");
+ }
+
+ /** Reads the full certificate chain from the specified path as a string. */
+ static String readCertificateChain(String certPath) throws IOException {
+ return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Verifies that the private key corresponds to the public key in the certificate by performing a
+ * test signature and verification.
+ */
+ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) {
+ try {
+ byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8);
+
+ String keyAlgorithm = cert.getPublicKey().getAlgorithm();
+ String sigAlg;
+ if ("RSA".equals(keyAlgorithm)) {
+ sigAlg = "SHA256withRSA";
+ } else if ("EC".equals(keyAlgorithm)) {
+ sigAlg = "SHA256withECDSA";
+ } else {
+ throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm);
+ }
+
+ Signature signer = Signature.getInstance(sigAlg);
+ signer.initSign(privateKey);
+ signer.update(data);
+ byte[] signature = signer.sign();
+
+ Signature verifier = Signature.getInstance(sigAlg);
+ verifier.initVerify(cert.getPublicKey());
+ verifier.update(data);
+
+ return verifier.verify(signature);
+ } catch (Exception e) {
+ LOGGER.warn("Key pair verification failed", e);
+ return false;
+ }
+ }
+
+ /** Reads the private key from the specified path using PKCS8 format. */
+ static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException {
+ String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
+ OAuth2Utils.Pkcs8Algorithm pkcs8Alg =
+ "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA;
+ return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg);
+ }
+
+ /**
+ * Determines if mTLS should be enabled based on environment variables and certificate presence.
+ */
+ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException {
+ String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
+
+ // Case 1: Explicitly enabled via environment variable
+ if ("true".equalsIgnoreCase(useClientCert)) {
+ if (certsPresent) {
+ // Certs are available, enable mTLS
+ return true;
+ }
+ if (configExists) {
+ // Config exists but files are missing - fail fast
+ throw new IOException(
+ "Certificate intent established via config, but cert files are missing.");
+ }
+ // Neither exist, do not enable
+ return false;
+ }
+ // Case 2: Explicitly disabled via environment variable
+ else if ("false".equalsIgnoreCase(useClientCert)) {
+ if (certsPresent) {
+ // Warn that we are ignoring present certs because it was explicitly disabled
+ Slf4jUtils.log(
+ LOGGER,
+ org.slf4j.event.Level.WARN,
+ Collections.emptyMap(),
+ "Token binding protection is disabled because mTLS was explicitly disabled via"
+ + " GOOGLE_API_USE_CLIENT_CERTIFICATE.");
+ return false;
+ }
+ return false;
+ }
+ // Case 3: Environment variable is unset
+ else {
+ if (certsPresent) {
+ // Infer mTLS is enabled because certs are present
+ return true;
+ }
+ if (configExists) {
+ // Config exists but files are missing - fail fast
+ throw new IOException(
+ "Certificate intent inferred via config, but cert files are missing.");
+ }
+ // Neither cert-config nor certs exist, do not enable
+ return false;
+ }
+ }
+
+ /** Retrieves the bound token payload (certificate chain) if applicable. */
+ static String getBoundTokenPayload() throws IOException {
+ CertInfo info = getAgentIdentityCertInfo();
+ if (info != null && shouldRequestBoundToken(info.certificate)) {
+ return info.certContent;
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ /** Extracts the certificate and private key paths from the JSON configuration file. */
+ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath)
+ throws IOException {
+ try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) {
+ JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
+ GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class);
+ Object certConfigsObj = config.get("cert_configs");
+ if (certConfigsObj instanceof Map) {
+ Map certConfigs = (Map) certConfigsObj;
+ Object workloadObj = certConfigs.get("workload");
+ if (workloadObj instanceof Map) {
+ Map workload = (Map) workloadObj;
+ String certPath = null;
+ String keyPath = null;
+ if (workload.get("cert_path") instanceof String) {
+ certPath = (String) workload.get("cert_path");
+ }
+ if (workload.get("key_path") instanceof String) {
+ keyPath = (String) workload.get("key_path");
+ }
+ return new ResolvedCertAndKeyPaths(certPath, keyPath);
+ }
+ }
+ } catch (java.nio.file.AccessDeniedException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException("Failed to parse Agent Identity config JSON", e);
+ }
+ return null;
+ }
+
+ /** Parses the X509 certificate from the specified content string. */
+ private static X509Certificate parseCertificateContent(String certContent) throws IOException {
+ try (InputStream stream =
+ new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) {
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ return (X509Certificate) cf.generateCertificate(stream);
+ } catch (GeneralSecurityException e) {
+ throw new IOException(
+ "Failed to parse Agent Identity certificate for bound token request.", e);
+ }
+ }
+
+ /**
+ * Determines if a bound token should be requested by checking if any of the certificate's Subject
+ * Alternative Names (SANs) match allowed SPIFFE patterns.
+ */
+ static boolean shouldRequestBoundToken(X509Certificate cert) {
+ try {
+ Collection> sans = cert.getSubjectAlternativeNames();
+ if (sans == null) {
+ return false;
+ }
+ // Iterate through all Subject Alternative Names
+ for (List> san : sans) {
+ // Check if the SAN entry is a URI (type 6)
+ if (san.size() >= 2
+ && san.get(0) instanceof Integer
+ && (Integer) san.get(0) == SAN_URI_TYPE) {
+ Object value = san.get(1);
+ if (value instanceof String) {
+ String uri = (String) value;
+ // Check if the URI starts with "spiffe://"
+ if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) {
+ String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length());
+ int slashIndex = withoutScheme.indexOf('/');
+ // Extract the trust domain (part before the first slash)
+ String trustDomain =
+ (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex);
+ // Match the trust domain against allowed agent patterns
+ for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) {
+ if (pattern.matcher(trustDomain).matches()) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (CertificateParsingException e) {
+ LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e);
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ public static void setEnvReader(EnvReader reader) {
+ envReader = reader;
+ }
+
+ @VisibleForTesting
+ static void setTimeService(TimeService service) {
+ timeService = service;
+ }
+
+ @VisibleForTesting
+ static void resetTimeService() {
+ timeService =
+ new TimeService() {
+ @Override
+ public long currentTimeMillis() {
+ return System.currentTimeMillis();
+ }
+
+ @Override
+ public void sleep(long millis) throws InterruptedException {
+ Thread.sleep(millis);
+ }
+ };
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
index ad5fb8e7dcf3..7dfa72bdeef0 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
@@ -61,6 +61,7 @@
import java.io.ObjectInputStream;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
@@ -317,7 +318,7 @@ public String getUniverseDomain() throws IOException {
private String getUniverseDomainFromMetadata() throws IOException {
HttpResponse response =
- getMetadataResponse(getUniverseDomainUrl(), RequestType.UNTRACKED, false);
+ getMetadataResponse(getUniverseDomainUrl(), "GET", null, RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
return Credentials.GOOGLE_DEFAULT_UNIVERSE;
@@ -376,7 +377,8 @@ public String getProjectId() {
private String getProjectIdFromMetadata() {
try {
- HttpResponse response = getMetadataResponse(getProjectIdUrl(), RequestType.UNTRACKED, false);
+ HttpResponse response =
+ getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
LoggingUtils.log(
@@ -418,8 +420,21 @@ private String getProjectIdFromMetadata() {
/** Refresh the access token by getting it from the GCE metadata server */
@Override
public AccessToken refreshAccessToken() throws IOException {
- HttpResponse response =
- getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true);
+ String tokenUrl = createTokenUrlWithScopes();
+
+ String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload();
+ HttpResponse response;
+
+ if (boundTokenPayload != null) {
+ java.util.Map
>singleton(dnsSan));
+ assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert));
+ }
+
+ @Test
+ public void shouldRequestBoundToken_noSan_returnsFalse() throws CertificateException {
+ X509Certificate mockCert = mock(X509Certificate.class);
+ when(mockCert.getSubjectAlternativeNames()).thenReturn(null);
+ assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert));
+ }
+
+ private X509Certificate mockCertWithSanUri(String uri) throws CertificateException {
+ X509Certificate mockCert = mock(X509Certificate.class);
+ List> spiffeEntry = Arrays.asList(6, uri);
+ Collection
> sans = Collections.singletonList(spiffeEntry);
+ when(mockCert.getSubjectAlternativeNames()).thenReturn(sans);
+ return mockCert;
+ }
+
+ @Test
+ public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException {
+ envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true");
+ envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path");
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException {
+ AgentIdentityUtils.setTimeService(new FakeTimeService());
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOException {
+ URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem");
+ assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found");
+ String certPath = new File(certUrl.getFile()).getAbsolutePath();
+ File configFile = tempDir.resolve("config.json").toFile();
+ String configJson =
+ "{"
+ + " \"cert_configs\": {"
+ + " \"workload\": {"
+ + " \"cert_path\": \""
+ + certPath.replace("\\", "\\\\")
+ + "\""
+ + " }"
+ + " }"
+ + "}";
+ try (FileOutputStream fos = new FileOutputStream(configFile)) {
+ fos.write(configJson.getBytes(StandardCharsets.UTF_8));
+ }
+ envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath());
+ AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo();
+ assertNotNull(info);
+ assertTrue(info.certificate.getIssuerDN().getName().contains("unit-tests"));
+ }
+
+ @Test
+ public void getAgentIdentityCertificate_timeout_throwsIOException() {
+ envProvider.setEnv(
+ "GOOGLE_API_CERTIFICATE_CONFIG",
+ tempDir.resolve("missing.json").toAbsolutePath().toString());
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+ AgentIdentityUtils.setTimeService(new FakeTimeService());
+ IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo);
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "Unable to find Agent Identity certificate config or file for bound token request"
+ + " after multiple retries."));
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_malformedJson_throwsIOException() throws IOException {
+ File configFile = tempDir.resolve("config.json").toFile();
+ try (FileOutputStream fos = new FileOutputStream(configFile)) {
+ fos.write("{ \"cert_configs\": invalid json} ".getBytes(StandardCharsets.UTF_8));
+ }
+ envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath());
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+ AgentIdentityUtils.setTimeService(new FakeTimeService());
+
+ IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo);
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "Unable to find Agent Identity certificate config or file for bound token request"
+ + " after multiple retries."));
+ }
+
+ @Test
+ public void shouldEnableMtls_true_certsPresent_returnsTrue() throws IOException {
+ envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true");
+ assertTrue(AgentIdentityUtils.shouldEnableMtls(true, true));
+ }
+
+ @Test
+ public void shouldEnableMtls_true_certsMissing_throwsIOException() {
+ envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true");
+ assertThrows(IOException.class, () -> AgentIdentityUtils.shouldEnableMtls(false, true));
+ }
+
+ @Test
+ public void shouldEnableMtls_false_certsPresent_returnsFalse() throws IOException {
+ envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false");
+ assertFalse(AgentIdentityUtils.shouldEnableMtls(true, true));
+ }
+
+ @Test
+ public void shouldEnableMtls_unset_certsPresent_returnsTrue() throws IOException {
+ envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null);
+ assertTrue(AgentIdentityUtils.shouldEnableMtls(true, true));
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOException {
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+
+ URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem");
+ assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found");
+ String certPath = new File(certUrl.getFile()).getAbsolutePath();
+
+ Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem"));
+
+ envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null);
+
+ AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo();
+ assertNotNull(info);
+ assertEquals(
+ new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8),
+ info.certContent);
+ }
+
+ @Test
+ public void verifyKeyPair_match_returnsTrue() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+ kpg.initialize(2048);
+ KeyPair kp = kpg.generateKeyPair();
+
+ X509Certificate mockCert = mock(X509Certificate.class);
+ when(mockCert.getPublicKey()).thenReturn(kp.getPublic());
+
+ assertTrue(AgentIdentityUtils.verifyKeyPair(mockCert, kp.getPrivate()));
+ }
+
+ @Test
+ public void verifyKeyPair_mismatch_returnsFalse() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+ kpg.initialize(2048);
+ KeyPair kp1 = kpg.generateKeyPair();
+ KeyPair kp2 = kpg.generateKeyPair();
+
+ X509Certificate mockCert = mock(X509Certificate.class);
+ when(mockCert.getPublicKey()).thenReturn(kp1.getPublic());
+
+ assertFalse(AgentIdentityUtils.verifyKeyPair(mockCert, kp2.getPrivate()));
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() throws Exception {
+ URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem");
+ assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found");
+ String certPath = new File(certUrl.getFile()).getAbsolutePath();
+
+ // Generate a random key that won't match the cert
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+ kpg.initialize(2048);
+ KeyPair kp = kpg.generateKeyPair();
+
+ File keyFile = tempDir.resolve("private_key.pem").toFile();
+ String keyPem =
+ "-----BEGIN PRIVATE KEY-----\n"
+ + java.util.Base64.getMimeEncoder().encodeToString(kp.getPrivate().getEncoded())
+ + "\n-----END PRIVATE KEY-----";
+ try (FileOutputStream fos = new FileOutputStream(keyFile)) {
+ fos.write(keyPem.getBytes(StandardCharsets.UTF_8));
+ }
+
+ File configFile = tempDir.resolve("config.json").toFile();
+ String configJson =
+ "{"
+ + " \"cert_configs\": {"
+ + " \"workload\": {"
+ + " \"cert_path\": \""
+ + certPath.replace("\\", "\\\\")
+ + "\","
+ + " \"key_path\": \""
+ + keyFile.getAbsolutePath().replace("\\", "\\\\")
+ + "\""
+ + " }"
+ + " }"
+ + "}";
+ try (FileOutputStream fos = new FileOutputStream(configFile)) {
+ fos.write(configJson.getBytes(StandardCharsets.UTF_8));
+ }
+
+ envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath());
+
+ FakeTimeService fakeTime = new FakeTimeService();
+ AgentIdentityUtils.setTimeService(fakeTime);
+
+ IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo);
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "Agent Identity certificate and private key mismatch or read failure after 3"
+ + " retries."));
+ assertEquals(200, fakeTime.currentTimeMillis()); // 2 retries * 100ms
+ }
+
+ private static class FakeTimeService implements AgentIdentityUtils.TimeService {
+ private final AtomicLong currentTime = new AtomicLong(0);
+
+ @Override
+ public long currentTimeMillis() {
+ return currentTime.get();
+ }
+
+ @Override
+ public void sleep(long millis) throws InterruptedException {
+ currentTime.addAndGet(millis);
+ }
+ }
+
+ private static class TestEnvironmentProvider {
+ private final java.util.Map