3535import com .google .auth .oauth2 .PropertyProvider ;
3636import com .google .common .annotations .VisibleForTesting ;
3737import com .google .common .base .Strings ;
38+ import com .google .common .collect .ImmutableList ;
3839import com .google .common .io .BaseEncoding ;
3940import java .io .File ;
4041import java .io .FileInputStream ;
4142import java .io .IOException ;
4243import java .io .InputStream ;
44+ import java .io .InterruptedIOException ;
45+ import java .nio .file .AccessDeniedException ;
4346import java .nio .file .Files ;
47+ import java .nio .file .Path ;
4448import java .nio .file .Paths ;
49+ import java .nio .file .attribute .BasicFileAttributes ;
4550import java .security .MessageDigest ;
4651import java .util .Locale ;
4752import org .jspecify .annotations .NullMarked ;
@@ -59,21 +64,258 @@ public class MtlsUtils {
5964 static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json" ;
6065 static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud" ;
6166
67+ /**
68+ * Well-known directory where workload SPIFFE credentials and certificate configurations are
69+ * delivered at container startup.
70+ */
71+ @ InternalApi
72+ public static final String WELL_KNOWN_SPIFFE_DIR =
73+ "/var/run/secrets/workload-spiffe-credentials/" ;
74+
75+ /**
76+ * Alternate well-known directory path where workload SPIFFE credentials and certificate
77+ * configurations may be mounted (for example, when {@code /run/secrets} is mounted directly or
78+ * referenced via resolved {@code /run} paths).
79+ */
80+ @ InternalApi
81+ public static final String WELL_KNOWN_RUN_SPIFFE_DIR =
82+ "/run/secrets/workload-spiffe-credentials/" ;
83+
84+ private static final ImmutableList <Path > DEFAULT_WELL_KNOWN_SPIFFE_DIRS =
85+ ImmutableList .of (
86+ Paths .get (WELL_KNOWN_SPIFFE_DIR ).toAbsolutePath ().normalize (),
87+ Paths .get (WELL_KNOWN_RUN_SPIFFE_DIR ).toAbsolutePath ().normalize ());
88+
6289 /**
6390 * Well-known path of the combined certificate chain and private key bundle that GKE delivers to
6491 * pods with Agent Identity (Pod Certificates). Used as a fallback client certificate source when
6592 * no certificate configuration file is configured or present.
6693 */
6794 static final String GKE_CREDENTIAL_BUNDLE_PATH =
68- "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem" ;
95+ WELL_KNOWN_SPIFFE_DIR + "x509.credential-bundle.private-key.pem" ;
96+
97+ // Polling configuration for initial container startup credential file readiness when
98+ // GOOGLE_API_CERTIFICATE_CONFIG and its referenced cert/key files reside in the well-known
99+ // SPIFFE directory (50 * 100ms + 50 * 500ms = 30 seconds total).
100+ private static final int FAST_POLL_CYCLES = 50 ;
101+ private static final long FAST_POLL_INTERVAL_MS = 100 ; // 0.1 seconds
102+ private static final long SLOW_POLL_INTERVAL_MS = 500 ; // 0.5 seconds
103+ private static final long TOTAL_TIMEOUT_MS = 30000 ; // 30 seconds
104+
105+ /** Total startup polling timeout in seconds (30s). */
106+ @ InternalApi public static final long STARTUP_POLL_TIMEOUT_SECONDS = TOTAL_TIMEOUT_MS / 1000 ;
107+
108+ private static final int TOTAL_POLL_CYCLES =
109+ FAST_POLL_CYCLES
110+ + (int )
111+ ((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS ))
112+ / SLOW_POLL_INTERVAL_MS );
69113
70114 // Overridable via setGkeCredentialBundlePathForTesting so unit tests can point to a temp file.
71115 private static volatile String gkeCredentialBundlePath = GKE_CREDENTIAL_BUNDLE_PATH ;
72116
117+ // Overridable via setWellKnownSpiffeDirForTesting so unit tests can point to a temp directory.
118+ private static volatile String wellKnownSpiffeDir = WELL_KNOWN_SPIFFE_DIR ;
119+
120+ private static volatile TimeService timeService = Thread ::sleep ;
121+
122+ /**
123+ * Functional interface for sleeping during startup credential polling. Overridable for testing.
124+ */
125+ @ InternalApi
126+ @ VisibleForTesting
127+ @ FunctionalInterface
128+ public interface TimeService {
129+ void sleep (long millis ) throws InterruptedException ;
130+ }
131+
73132 private MtlsUtils () {
74133 // Prevent instantiation for Utility class
75134 }
76135
136+ private static long getSleepIntervalMs (int cycle ) {
137+ return (cycle < FAST_POLL_CYCLES ) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS ;
138+ }
139+
140+ /**
141+ * Overrides the well-known SPIFFE directory path for testing; {@code null} restores the default.
142+ */
143+ @ VisibleForTesting
144+ static void setWellKnownSpiffeDirForTesting (@ Nullable String dir ) {
145+ wellKnownSpiffeDir = dir != null ? dir : WELL_KNOWN_SPIFFE_DIR ;
146+ }
147+
148+ /** Overrides the sleep service for testing; {@code null} restores {@code Thread::sleep}. */
149+ @ VisibleForTesting
150+ static void setTimeServiceForTesting (@ Nullable TimeService service ) {
151+ timeService = service != null ? service : Thread ::sleep ;
152+ }
153+
154+ /**
155+ * Checks whether {@code pathStr} resides within {@code targetWellKnownDir} (or either of the
156+ * default well-known SPIFFE directories {@link #WELL_KNOWN_SPIFFE_DIR} and {@link
157+ * #WELL_KNOWN_RUN_SPIFFE_DIR} when {@code targetWellKnownDir} is {@link #WELL_KNOWN_SPIFFE_DIR}).
158+ */
159+ @ InternalApi
160+ public static boolean isPathInWellKnownDir (@ Nullable String pathStr , String targetWellKnownDir ) {
161+ if (Strings .isNullOrEmpty (pathStr ) || Strings .isNullOrEmpty (targetWellKnownDir )) {
162+ return false ;
163+ }
164+ try {
165+ Path path = Paths .get (pathStr ).toAbsolutePath ().normalize ();
166+ if (WELL_KNOWN_SPIFFE_DIR .equals (targetWellKnownDir )) {
167+ for (Path defaultDir : DEFAULT_WELL_KNOWN_SPIFFE_DIRS ) {
168+ if (path .startsWith (defaultDir )) {
169+ return true ;
170+ }
171+ }
172+ return false ;
173+ }
174+ Path wellKnown = Paths .get (targetWellKnownDir ).toAbsolutePath ().normalize ();
175+ return path .startsWith (wellKnown );
176+ } catch (Exception e ) {
177+ return false ;
178+ }
179+ }
180+
181+ private static boolean isNonEmptyReadableFile (File file ) {
182+ return file .isFile () && file .canRead () && file .length () > 0 ;
183+ }
184+
185+ private static boolean isExistingNonFileOrUnreadable (File file ) {
186+ Path path = file .toPath ();
187+ try {
188+ BasicFileAttributes attrs = Files .readAttributes (path , BasicFileAttributes .class );
189+ if (!attrs .isRegularFile ()) {
190+ return true ;
191+ }
192+ return !Files .isReadable (path ) && Files .exists (path );
193+ } catch (AccessDeniedException e ) {
194+ return true ;
195+ } catch (IOException e ) {
196+ return false ;
197+ }
198+ }
199+
200+ /**
201+ * Polls up to 30 seconds when {@code configFile} and its referenced {@code cert_path} and {@code
202+ * key_path} reside within {@code targetWellKnownDir} until the configuration and referenced
203+ * certificate/key files are non-empty readable files, or a non-retryable state is encountered.
204+ *
205+ * @return {@code true} if startup polling was active for {@code configFile}, {@code false}
206+ * otherwise
207+ * @throws IOException if the thread is interrupted while sleeping during startup polling
208+ */
209+ @ InternalApi
210+ public static boolean waitForWorkloadConfigStartupIfNeeded (
211+ File configFile ,
212+ String targetWellKnownDir ,
213+ TimeService sleepService ,
214+ @ Nullable Runnable onFirstPollSleep )
215+ throws IOException {
216+ boolean shouldPoll = isPathInWellKnownDir (configFile .getPath (), targetWellKnownDir );
217+ try {
218+ waitForExplicitConfigStartupIfNeeded (
219+ configFile , targetWellKnownDir , sleepService , onFirstPollSleep );
220+ } catch (InterruptedIOException e ) {
221+ throw new IOException (
222+ "Interrupted while waiting for Agent Identity certificate files for bound"
223+ + " token request." ,
224+ e .getCause () != null ? e .getCause () : e );
225+ } catch (Exception ignored ) {
226+ // Non-retryable parse or config errors are handled by the caller's single-pass evaluation.
227+ }
228+ return shouldPoll ;
229+ }
230+
231+ /**
232+ * Polls up to 30 seconds when {@code configFile} and its referenced {@code cert_path} and {@code
233+ * key_path} reside within the well-known SPIFFE directory until the configuration and referenced
234+ * certificate/key files are non-empty readable files.
235+ */
236+ private static @ Nullable WorkloadCertificateConfiguration waitForExplicitConfigStartupIfNeeded (
237+ File configFile ) throws IOException {
238+ return waitForExplicitConfigStartupIfNeeded (configFile , wellKnownSpiffeDir , timeService , null );
239+ }
240+
241+ private static @ Nullable WorkloadCertificateConfiguration waitForExplicitConfigStartupIfNeeded (
242+ File configFile ,
243+ String targetWellKnownDir ,
244+ TimeService sleepService ,
245+ @ Nullable Runnable onFirstPollSleep )
246+ throws IOException {
247+ boolean shouldPoll = isPathInWellKnownDir (configFile .getPath (), targetWellKnownDir );
248+ int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1 ;
249+ boolean warned = false ;
250+
251+ for (int cycle = 0 ; cycle < maxCycles ; cycle ++) {
252+ if (isExistingNonFileOrUnreadable (configFile )) {
253+ // Directory or permission denied on an existing file will not resolve via startup polling.
254+ break ;
255+ }
256+ if (isNonEmptyReadableFile (configFile )) {
257+ try (InputStream certConfigStream = new FileInputStream (configFile )) {
258+ WorkloadCertificateConfiguration config =
259+ WorkloadCertificateConfiguration .fromCertificateConfigurationStream (certConfigStream );
260+ if (!shouldPoll ) {
261+ return config ;
262+ }
263+ File certFile = new File (config .getCertPath ());
264+ File keyFile = new File (config .getPrivateKeyPath ());
265+ if (isExistingNonFileOrUnreadable (certFile ) || isExistingNonFileOrUnreadable (keyFile )) {
266+ // Referenced certificate or key is a directory or unreadable; fail without polling.
267+ break ;
268+ }
269+ if (isNonEmptyReadableFile (certFile ) && isNonEmptyReadableFile (keyFile )) {
270+ return config ;
271+ }
272+ if (!isPathInWellKnownDir (config .getCertPath (), targetWellKnownDir )
273+ || !isPathInWellKnownDir (config .getPrivateKeyPath (), targetWellKnownDir )) {
274+ // Startup polling only applies when the config file AND its referenced cert_path and
275+ // key_path all reside within the well-known directory.
276+ break ;
277+ }
278+ } catch (CertificateSourceUnavailableException e ) {
279+ // Valid JSON without a workload section (e.g. ECP configuration); do not poll.
280+ throw e ;
281+ } catch (IllegalArgumentException | ClassCastException e ) {
282+ // Valid JSON missing or mistyped cert_configs, cert_path, or key_path; do not poll.
283+ throw e ;
284+ } catch (Exception e ) {
285+ if (!shouldPoll ) {
286+ if (e instanceof IOException ) {
287+ throw (IOException ) e ;
288+ }
289+ if (e instanceof RuntimeException ) {
290+ throw (RuntimeException ) e ;
291+ }
292+ throw new IOException (e );
293+ }
294+ // When polling in the well-known SPIFFE directory, retry on transient partial JSON
295+ // writes (which may throw IOException or NullPointerException at EOF in JsonParser).
296+ }
297+ }
298+ if (cycle + 1 < maxCycles ) {
299+ if (shouldPoll && !warned && onFirstPollSleep != null ) {
300+ onFirstPollSleep .run ();
301+ warned = true ;
302+ }
303+ try {
304+ sleepService .sleep (getSleepIntervalMs (cycle ));
305+ } catch (InterruptedException e ) {
306+ Thread .currentThread ().interrupt ();
307+ InterruptedIOException interrupted =
308+ new InterruptedIOException (
309+ "Interrupted while waiting for workload certificate configuration at "
310+ + configFile .getPath ());
311+ interrupted .initCause (e );
312+ throw interrupted ;
313+ }
314+ }
315+ }
316+ return null ;
317+ }
318+
77319 /**
78320 * Returns {@code true} if a certificate configuration is configured or present: either {@code
79321 * GOOGLE_API_CERTIFICATE_CONFIG} is set, or the well-known gcloud {@code certificate_config.json}
@@ -194,6 +436,20 @@ public static boolean useMtlsClientCertificate(
194436 // 1. Explicit Configuration Path (Fail Closed)
195437 if (!Strings .isNullOrEmpty (explicitConfigPath )) {
196438 File configFile = new File (explicitConfigPath );
439+ WorkloadCertificateConfiguration config = null ;
440+ try {
441+ config = waitForExplicitConfigStartupIfNeeded (configFile );
442+ } catch (CertificateSourceUnavailableException e ) {
443+ // ECP / PKCS11 configuration without workload section; safe fallback
444+ return null ;
445+ } catch (Exception e ) {
446+ throw new IllegalStateException (
447+ "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
448+ + explicitConfigPath
449+ + "' is malformed: "
450+ + e .getMessage (),
451+ e );
452+ }
197453 if (!configFile .exists ()) {
198454 throw new IllegalStateException (
199455 "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
@@ -207,19 +463,21 @@ public static boolean useMtlsClientCertificate(
207463 + explicitConfigPath
208464 + "'." );
209465 }
210- WorkloadCertificateConfiguration config ;
211- try {
212- config = getWorkloadCertificateConfiguration (envProvider , propProvider , explicitConfigPath );
213- } catch (CertificateSourceUnavailableException e ) {
214- // ECP / PKCS11 configuration without workload section; safe fallback
215- return null ;
216- } catch (Exception e ) {
217- throw new IllegalStateException (
218- "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
219- + explicitConfigPath
220- + "' is malformed: "
221- + e .getMessage (),
222- e );
466+ if (config == null ) {
467+ try (InputStream certConfigStream = new FileInputStream (configFile )) {
468+ config =
469+ WorkloadCertificateConfiguration .fromCertificateConfigurationStream (certConfigStream );
470+ } catch (CertificateSourceUnavailableException e ) {
471+ // ECP / PKCS11 configuration without workload section; safe fallback
472+ return null ;
473+ } catch (Exception e ) {
474+ throw new IllegalStateException (
475+ "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
476+ + explicitConfigPath
477+ + "' is malformed: "
478+ + e .getMessage (),
479+ e );
480+ }
223481 }
224482 checkCertAndKeyFilesReadable (config , explicitConfigPath , false );
225483 return config .getCertPath ();
@@ -270,7 +528,7 @@ private static void checkCertAndKeyFilesReadable(
270528 WorkloadCertificateConfiguration config , String configPath , boolean isDefaultConfig ) {
271529 File certFile = new File (config .getCertPath ());
272530 File keyFile = new File (config .getPrivateKeyPath ());
273- if (!certFile . isFile () || ! certFile . canRead ( ) || !keyFile . isFile () || ! keyFile . canRead ( )) {
531+ if (!isNonEmptyReadableFile ( certFile ) || !isNonEmptyReadableFile ( keyFile )) {
274532 String sourcePrefix =
275533 isDefaultConfig
276534 ? "referenced by default configuration '"
@@ -353,17 +611,30 @@ static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration(
353611 @ Nullable String certConfigPathOverride )
354612 throws IOException {
355613 File certConfig ;
614+ boolean isExplicitConfig = false ;
356615 if (certConfigPathOverride != null ) {
357616 certConfig = new File (certConfigPathOverride );
617+ isExplicitConfig = true ;
358618 } else {
359619 String envCredentialsPath = envProvider .getEnv (CERTIFICATE_CONFIGURATION_ENV_VARIABLE );
360620 if (!Strings .isNullOrEmpty (envCredentialsPath )) {
361621 certConfig = new File (envCredentialsPath );
622+ isExplicitConfig = true ;
362623 } else {
363624 certConfig = getWellKnownCertificateConfigFile (envProvider , propProvider );
364625 }
365626 }
366627
628+ String rawUseClientCert = envProvider .getEnv ("GOOGLE_API_USE_CLIENT_CERTIFICATE" );
629+ String useClientCert = rawUseClientCert != null ? rawUseClientCert .trim () : null ;
630+ if (isExplicitConfig && !isUseClientCertificateExplicitlyDisabled (useClientCert )) {
631+ WorkloadCertificateConfiguration polledConfig =
632+ waitForExplicitConfigStartupIfNeeded (certConfig );
633+ if (polledConfig != null ) {
634+ return polledConfig ;
635+ }
636+ }
637+
367638 if (!certConfig .isFile () || !certConfig .canRead ()) {
368639 throw new CertificateSourceUnavailableException (
369640 "Certificate configuration file does not exist or is not a file: "
0 commit comments