3030 */
3131package com .google .auth .oauth2 ;
3232
33+ import com .google .api .core .InternalApi ;
3334import com .google .api .client .json .GenericJson ;
3435import com .google .api .client .json .JsonObjectParser ;
3536import com .google .common .annotations .VisibleForTesting ;
3637import com .google .common .base .Strings ;
3738import com .google .common .collect .ImmutableList ;
38- import com .google .common .io .BaseEncoding ;
3939import java .io .FileInputStream ;
4040import java .io .IOException ;
4141import java .io .InputStream ;
42- import java .net .URLEncoder ;
4342import java .nio .charset .StandardCharsets ;
4443import java .nio .file .Files ;
4544import java .nio .file .Paths ;
4645import java .security .GeneralSecurityException ;
47- import java .security .MessageDigest ;
4846import java .security .PrivateKey ;
4947import java .security .Signature ;
5048import java .security .cert .CertificateFactory ;
6058import org .slf4j .LoggerFactory ;
6159
6260/** Utility class for Agent Identity token binding in Cloud Run. */
63- class AgentIdentityUtils {
61+ @ InternalApi
62+ public final class AgentIdentityUtils {
6463
6564 private static final Logger LOGGER = LoggerFactory .getLogger (AgentIdentityUtils .class );
6665
@@ -104,7 +103,7 @@ static void setWellKnownDir(String dir) {
104103 POLLING_INTERVALS = Collections .unmodifiableList (intervals );
105104 }
106105
107- interface EnvReader {
106+ public interface EnvReader {
108107 String getEnv (String name );
109108 }
110109
@@ -142,21 +141,66 @@ static class CertInfo {
142141 }
143142 }
144143
144+ static class ResolvedCertAndKeyPaths {
145+ final String certPath ;
146+ final String keyPath ;
147+
148+ ResolvedCertAndKeyPaths (String certPath , String keyPath ) {
149+ this .certPath = certPath ;
150+ this .keyPath = keyPath ;
151+ }
152+ }
153+
154+ /**
155+ * Retrieves the certificate and path for the Agent Identity.
156+ *
157+ * <p>This method attempts to load the certificate and private key for the agent identity. It
158+ * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment
159+ * variable. If not set, it falls back to well-known default locations.
160+ *
161+ * <p>To handle transient race conditions during certificate rotation on disk, this method employs
162+ * a retry mechanism with backoff when reading the configuration and certificate files.
163+ *
164+ * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code
165+ * null} if the agent identity features are disabled, opted out, or if no valid credentials
166+ * could be loaded.
167+ * @throws IOException If an I/O error occurs while reading the files, or if the key-pair
168+ * verification fails after retries.
169+ */
145170 static CertInfo getAgentIdentityCertInfo () throws IOException {
146171 if (isOptedOut ()) {
147172 return null ;
148173 }
149174 String certConfigPath = envReader .getEnv (GOOGLE_API_CERTIFICATE_CONFIG );
150-
151175 boolean configExists =
152176 !Strings .isNullOrEmpty (certConfigPath ) && Files .exists (Paths .get (certConfigPath ));
177+
178+ ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths (certConfigPath );
179+ boolean certsPresent = !Strings .isNullOrEmpty (paths .certPath );
180+
181+ if (!shouldEnableMtls (certsPresent , configExists )) {
182+ return null ;
183+ }
184+
185+ return loadAndVerifyCredentials (paths .certPath , paths .keyPath );
186+ }
187+
188+ /**
189+ * Resolves the paths for the certificate and private key based on the config path or well-known
190+ * locations.
191+ */
192+ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths (String certConfigPath ) throws IOException {
153193 String certPath = null ;
154194 String keyPath = null ;
155195
156196 if (!Strings .isNullOrEmpty (certConfigPath )) {
197+ // Read cert path from config file. We use retry with backoff to handle transient race
198+ // conditions where the config file might be being updated by a rotation process.
157199 certPath = getCertificatePathWithRetry (certConfigPath );
158200 keyPath = extractKeyPathFromConfig (certConfigPath );
159201 } else {
202+ // Fallback to well-known locations. We use retry with backoff here as well to handle
203+ // race conditions during file replacement by a rotation process.
160204 certPath = getWellKnownCertificatePathWithRetry ();
161205 if (certPath != null ) {
162206 if (certPath .endsWith ("credentialbundle.pem" )) {
@@ -166,13 +210,13 @@ static CertInfo getAgentIdentityCertInfo() throws IOException {
166210 }
167211 }
168212 }
213+ return new ResolvedCertAndKeyPaths (certPath , keyPath );
214+ }
169215
170- boolean certsPresent = !Strings .isNullOrEmpty (certPath );
171-
172- if (!shouldEnableMtls (certsPresent , configExists )) {
173- return null ;
174- }
175-
216+ /**
217+ * Loads the certificate and private key, and verifies that they match if they are separate files.
218+ */
219+ static CertInfo loadAndVerifyCredentials (String certPath , String keyPath ) throws IOException {
176220 X509Certificate cert = null ;
177221 PrivateKey privateKey = null ;
178222
@@ -220,11 +264,18 @@ static CertInfo getAgentIdentityCertInfo() throws IOException {
220264 return new CertInfo (cert , certPath );
221265 }
222266
267+ /**
268+ * Checks if the user has opted out of token sharing by setting the environment variable to true.
269+ */
223270 private static boolean isOptedOut () {
224271 String optOut = envReader .getEnv (GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES );
225- return "false " .equalsIgnoreCase (optOut );
272+ return "true " .equalsIgnoreCase (optOut );
226273 }
227274
275+ /**
276+ * Reads the certificate path from the config file with retry logic to handle rotation race
277+ * conditions.
278+ */
228279 private static String getCertificatePathWithRetry (String certConfigPath ) throws IOException {
229280 boolean warned = false ;
230281 for (long sleepInterval : POLLING_INTERVALS ) {
@@ -263,6 +314,7 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws
263314 + " to false to fall back to unbound tokens." );
264315 }
265316
317+ /** Searches for certificates at well-known locations with retry logic. */
266318 private static String getWellKnownCertificatePathWithRetry () throws IOException {
267319 String bundlePath = Paths .get (wellKnownDir , "credentialbundle.pem" ).toString ();
268320 String certOnlyPath = Paths .get (wellKnownDir , "certificates.pem" ).toString ();
@@ -300,10 +352,15 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException
300352 "Unable to find well-known certificate file for bound token request after multiple retries." );
301353 }
302354
355+ /** Reads the full certificate chain from the specified path as a string. */
303356 static String readCertificateChain (String certPath ) throws IOException {
304357 return new String (Files .readAllBytes (Paths .get (certPath )), StandardCharsets .UTF_8 );
305358 }
306359
360+ /**
361+ * Verifies that the private key corresponds to the public key in the certificate by performing a
362+ * test signature and verification.
363+ */
307364 static boolean verifyKeyPair (X509Certificate cert , PrivateKey privateKey ) {
308365 try {
309366 byte [] data = "verification-data" .getBytes (StandardCharsets .UTF_8 );
@@ -334,27 +391,38 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) {
334391 }
335392 }
336393
394+ /** Reads the private key from the specified path using PKCS8 format. */
337395 static PrivateKey readPrivateKey (String keyPath , String algorithm ) throws IOException {
338396 String keyPem = new String (Files .readAllBytes (Paths .get (keyPath )), StandardCharsets .UTF_8 );
339397 OAuth2Utils .Pkcs8Algorithm pkcs8Alg =
340398 "EC" .equals (algorithm ) ? OAuth2Utils .Pkcs8Algorithm .EC : OAuth2Utils .Pkcs8Algorithm .RSA ;
341399 return OAuth2Utils .privateKeyFromPkcs8 (keyPem , pkcs8Alg );
342400 }
343401
402+ /**
403+ * Determines if mTLS should be enabled based on environment variables and certificate presence.
404+ */
344405 static boolean shouldEnableMtls (boolean certsPresent , boolean configExists ) throws IOException {
345406 String useClientCert = envReader .getEnv ("GOOGLE_API_USE_CLIENT_CERTIFICATE" );
346407
408+ // Case 1: Explicitly enabled via environment variable
347409 if ("true" .equalsIgnoreCase (useClientCert )) {
348410 if (certsPresent ) {
411+ // Certs are available, enable mTLS
349412 return true ;
350413 }
351414 if (configExists ) {
415+ // Config exists but files are missing - fail fast
352416 throw new IOException (
353417 "Certificate intent established via config, but cert files are missing." );
354418 }
419+ // Neither exist, do not enable
355420 return false ;
356- } else if ("false" .equalsIgnoreCase (useClientCert )) {
421+ }
422+ // Case 2: Explicitly disabled via environment variable
423+ else if ("false" .equalsIgnoreCase (useClientCert )) {
357424 if (certsPresent ) {
425+ // Warn that we are ignoring present certs because it was explicitly disabled
358426 Slf4jUtils .log (
359427 LOGGER ,
360428 org .slf4j .event .Level .WARN ,
@@ -363,18 +431,24 @@ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) thro
363431 return false ;
364432 }
365433 return false ;
366- } else {
434+ }
435+ // Case 3: Environment variable is unset
436+ else {
367437 if (certsPresent ) {
438+ // Infer mTLS is enabled because certs are present
368439 return true ;
369440 }
370441 if (configExists ) {
442+ // Config exists but files are missing - fail fast
371443 throw new IOException (
372- "Certificate intent inferred via config, but cert files are missing." ); // Case 7
444+ "Certificate intent inferred via config, but cert files are missing." );
373445 }
374- return false ; // Case 8
446+ // Neither cert-config nor certsexist, do not enable
447+ return false ;
375448 }
376449 }
377450
451+ /** Retrieves the bound token payload (certificate chain) if applicable. */
378452 static String getBoundTokenPayload () throws IOException {
379453 CertInfo info = getAgentIdentityCertInfo ();
380454 if (info != null && shouldRequestBoundToken (info .certificate )) {
@@ -384,6 +458,7 @@ static String getBoundTokenPayload() throws IOException {
384458 }
385459
386460 @ SuppressWarnings ("unchecked" )
461+ /** Extracts the certificate path from the JSON configuration file. */
387462 private static String extractCertPathFromConfig (String certConfigPath ) throws IOException {
388463 try (InputStream stream = new FileInputStream (certConfigPath )) {
389464 JsonObjectParser parser = new JsonObjectParser (OAuth2Utils .JSON_FACTORY );
@@ -400,6 +475,7 @@ private static String extractCertPathFromConfig(String certConfigPath) throws IO
400475 }
401476
402477 @ SuppressWarnings ("unchecked" )
478+ /** Extracts the private key path from the JSON configuration file. */
403479 private static String extractKeyPathFromConfig (String certConfigPath ) throws IOException {
404480 try (InputStream stream = new FileInputStream (certConfigPath )) {
405481 JsonObjectParser parser = new JsonObjectParser (OAuth2Utils .JSON_FACTORY );
@@ -415,6 +491,7 @@ private static String extractKeyPathFromConfig(String certConfigPath) throws IOE
415491 return null ;
416492 }
417493
494+ /** Parses the X509 certificate from the specified path. */
418495 private static X509Certificate parseCertificate (String certPath ) throws IOException {
419496 try (InputStream stream = new FileInputStream (certPath )) {
420497 CertificateFactory cf = CertificateFactory .getInstance ("X.509" );
@@ -425,24 +502,33 @@ private static X509Certificate parseCertificate(String certPath) throws IOExcept
425502 }
426503 }
427504
505+ /**
506+ * Determines if a bound token should be requested by checking if any of the certificate's Subject
507+ * Alternative Names (SANs) match allowed SPIFFE patterns.
508+ */
428509 static boolean shouldRequestBoundToken (X509Certificate cert ) {
429510 try {
430511 Collection <List <?>> sans = cert .getSubjectAlternativeNames ();
431512 if (sans == null ) {
432513 return false ;
433514 }
515+ // Iterate through all Subject Alternative Names
434516 for (List <?> san : sans ) {
517+ // Check if the SAN entry is a URI (type 6)
435518 if (san .size () >= 2
436519 && san .get (0 ) instanceof Integer
437520 && (Integer ) san .get (0 ) == SAN_URI_TYPE ) {
438521 Object value = san .get (1 );
439522 if (value instanceof String ) {
440523 String uri = (String ) value ;
524+ // Check if the URI starts with "spiffe://"
441525 if (uri .startsWith (SPIFFE_SCHEME_PREFIX )) {
442526 String withoutScheme = uri .substring (SPIFFE_SCHEME_PREFIX .length ());
443527 int slashIndex = withoutScheme .indexOf ('/' );
528+ // Extract the trust domain (part before the first slash)
444529 String trustDomain =
445530 (slashIndex == -1 ) ? withoutScheme : withoutScheme .substring (0 , slashIndex );
531+ // Match the trust domain against allowed agent patterns
446532 for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS ) {
447533 if (pattern .matcher (trustDomain ).matches ()) {
448534 return true ;
@@ -458,21 +544,8 @@ static boolean shouldRequestBoundToken(X509Certificate cert) {
458544 return false ;
459545 }
460546
461- static String calculateCertificateFingerprint (X509Certificate cert ) throws IOException {
462- try {
463- MessageDigest md = MessageDigest .getInstance ("SHA-256" );
464- byte [] der = cert .getEncoded ();
465- md .update (der );
466- byte [] digest = md .digest ();
467- String base64Fingerprint = BaseEncoding .base64 ().omitPadding ().encode (digest );
468- return URLEncoder .encode (base64Fingerprint , "UTF-8" );
469- } catch (GeneralSecurityException e ) {
470- throw new IOException ("Failed to calculate fingerprint for Agent Identity certificate." , e );
471- }
472- }
473-
474547 @ VisibleForTesting
475- static void setEnvReader (EnvReader reader ) {
548+ public static void setEnvReader (EnvReader reader ) {
476549 envReader = reader ;
477550 }
478551
0 commit comments