Skip to content

fix(auth): present the full certificate chain for mTLS - #14621

Draft
macastelaz wants to merge 6 commits into
googleapis:agentic-identities-bound-tokenfrom
macastelaz:mtls-full-cert-chain
Draft

macastelaz wants to merge 6 commits into
googleapis:agentic-identities-bound-tokenfrom
macastelaz:mtls-full-cert-chain

Conversation

@macastelaz

@macastelaz macastelaz commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Note

Stacked on #14595. Until #14595 merges, this diff also shows its two commits. Only the last two commits (fix(auth): present the full certificate chain for mTLS and fix(auth): match upstream private key selection and harden chain parsing) are new here. Once #14595 merges, I'll merge the base branch in so the diff shows just this change.

Why

X509Provider and SecureConnectProvider build their mTLS key store with google-http-client's SecurityUtils.createMtlsKeyStore. That method keeps only the first CERTIFICATE in the PEM input, so Java presents only the leaf certificate in the TLS handshake. This is unchanged in the latest google-http-client release (2.2.0).

  • Google APIs over *.mtls.googleapis.com are unaffected, because Google's front ends already know the intermediates.
  • Peers that trust only a root CA can't verify the leaf without the intermediate, so they reject the handshake.
    • A common case is agent-to-agent mTLS, where the receiving agent trusts the SPIFFE trust bundle.
    • Agent identity certificates on GKE (and Cloud Run) are issued by an intermediate CA, so a Java agent that reuses the SDK's mTLS key store can't call such a peer.
    • Python sends the whole chain (load_cert_chain).

This bug predates the agent identity work; the agent identity testing caught it.

What changes

New package-private MtlsKeyStoreUtils.createMtlsKeyStore(InputStream). It replaces the upstream method.

  • The change: the key entry's chain holds every certificate, in input order. The first certificate is still the leaf.
  • Same as upstream: key store type (JKS), alias, empty password, accepted key format (PKCS#8 PRIVATE KEY), which private key is chosen when there are several, and error messages.
  • Also different from upstream: the input is decoded as UTF-8 instead of the platform default charset, and it's read to the end. Upstream stopped once it had a certificate and a key.

Used by:

  • X509Provider.getKeyStore(), on both the certificate-configuration path (Cloud Run, WIF X.509) and the GKE credential-bundle path;
  • SecureConnectProvider.getKeyStore() (ECP).

Inputs that worked before still work. Because more of the input is now parsed, once a certificate and a private key have been found (the point where upstream stopped reading):

  • a malformed or unreadable later section ends the input instead of failing;
  • an extra certificate that can't be parsed is left out of the chain.

Unchanged:

  • exception wrapping in the providers;
  • the token side: the cnf binding is still the leaf thumbprint, read from the certificate file;
  • the public API (no clirr impact).

Known limitations (unchanged from before):

  • The SecureConnect helper's stdout is now read to the end. Previously reading stopped once a certificate and key had been read. The process has already exited by then, so this only matters if the helper leaves a background process holding stdout open.
  • GAX's deprecated com.google.api.gax.rpc.mtls.MtlsProvider (ECP-only) still uses the upstream method. GAX's transports don't use it; they use com.google.auth.mtls.MtlsProvider through DefaultMtlsProviderFactory.
  • There's no check that the private key matches the first certificate (same as upstream).

Tests

Unit tests

  • MtlsKeyStoreUtilsTest (new), using a generated test chain (root → intermediate → leaf, EC P-256, long validity) in testresources/mtls/test_chain_{cert,key}.pem:
    • upstream SecurityUtils.createMtlsKeyStore keeps only the leaf for the same input. This pins down the bug, and the test fails if upstream ever fixes it;
    • leaf + intermediate in order, with the certificates first or the key first;
    • the private key matches the leaf (sign/verify);
    • a single certificate gives the same alias, type, certificate and key as SecurityUtils.createMtlsKeyStore;
    • non-certificate sections are ignored;
    • with several private keys, the same key is chosen as upstream: the last one before the first certificate, or the first one after it. Each of these tests runs upstream on the same input and compares;
    • a malformed trailing section is ignored;
    • an unparsable extra certificate is left out;
    • an unparsable leaf, a missing certificate, or a missing key throws, with the same messages as before.
  • X509ProviderTest: a GKE bundle with an intermediate (certificates first, and key first as GKE writes it), and a certificate config whose cert_path holds a chain, all give leaf + intermediate.
  • SecureConnectProviderTest: helper output with a chain gives leaf + intermediate.

The provider tests fail without the fix (expected: <2> but was: <1>). Full oauth2_http suite: 1107 tests, 0 failures. fmt is clean.

Live tests

Environment Check Before After
GKE agent identity Agent-to-agent mTLS using the SDK's DefaultMtlsProviderFactory key store, against a receiver that trusts only the SPIFFE root Broken pipe (handshake rejected) 200
GKE agent identity GCS over storage.mtls.googleapis.com, GAX clients over gRPC and HTTP/JSON, bound tokens pass pass (unchanged)
Workload identity federation X.509 (STS mTLS) Token exchange with a leaf-only cert_path success, leaf sent success, leaf sent
Workload identity federation X.509 (STS mTLS) Token exchange with a leaf + intermediate cert_path success, leaf only sent success, leaf + intermediate sent

The certificates sent were confirmed from the TLS handshake debug log. The live tests ran on the first commit (b2876a4d3a9). The second commit only changes how unusual or malformed inputs are handled, plus tests, so well-formed GKE and WIF files behave the same.

Internal links (Googlers only): live test results · GKE setup, manifests and harness (shared with #14595)

…kens and mTLS

On GKE, agent identity credentials are delivered as a single combined PEM
file (certificate chain + PKCS#8 private key) at
/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem.

When no certificate configuration exists (GOOGLE_API_CERTIFICATE_CONFIG unset
and no ~/.config/gcloud/certificate_config.json), the presence of a readable,
non-empty bundle now:

- auto-enables mTLS on the client transport (MtlsUtils.useMtlsClientCertificate
  / getWorkloadCertPath, and the X509Provider created by
  DefaultMtlsProviderFactory), and
- lets ComputeEngineCredentials request certificate-bound tokens with the same
  certificate (AgentIdentityUtils).

GOOGLE_API_USE_CLIENT_CERTIFICATE=false and
GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false still opt out. The bundle is never
polled for. The GKE fallback is limited to the transport factory path, so
workload identity federation X.509 credentials are unchanged.

This mirrors googleapis/google-cloud-python#18559.
X509Provider and SecureConnectProvider built their key stores with
google-http-client's SecurityUtils.createMtlsKeyStore, which keeps only
the first certificate. Java therefore presented only the leaf during the
TLS handshake. Google front ends accept that, but peers that trust only
the root (for example, agent-to-agent mTLS with a SPIFFE trust bundle)
cannot verify the leaf without the intermediate and reject the handshake.

Build the key store with every certificate in the PEM input, keeping the
first certificate as the leaf, as before.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for GKE credential bundles (Pod Certificates) as a fallback client certificate source for mTLS when no explicit certificate configuration is present. It adds MtlsKeyStoreUtils to build a KeyStore that preserves the full certificate chain, and updates MtlsUtils, X509Provider, and AgentIdentityUtils to handle the GKE credential bundle path and establish mTLS intent accordingly. The review feedback suggests two improvements in MtlsKeyStoreUtils: normalizing the "ECDSA" public key algorithm name to "EC" to prevent NoSuchAlgorithmException on standard JDKs, and explicitly calling keyStore.load(null, null) instead of keyStore.load(null) to avoid relying on implicit JDK fallback behavior.

- Choose the private key the same way as SecurityUtils.createMtlsKeyStore:
  a later key replaces an earlier one until the first certificate is seen.
- Once a certificate and key are found, also treat a read error as the end
  of input, and leave out an extra certificate that fails with a runtime
  exception, so inputs the upstream method accepted keep working.
- Javadoc: describe the differences from upstream and complete @throws;
  X509Provider.getKeyStore now returns the certificate chain.
- Tests: compare key selection with upstream for keys before and after the
  certificates, pin that upstream keeps only the leaf, cover a key-first GKE
  bundle with an intermediate, and make a test independent of line endings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant