Skip to content

feat(auth): add _MtlsCertManager for mTLS client certificate rotation - #18925

Open
nbayati wants to merge 8 commits into
mainfrom
grpc_phase1_pr1
Open

nbayati wants to merge 8 commits into
mainfrom
grpc_phase1_pr1

Conversation

@nbayati

@nbayati nbayati commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Adds _MtlsCertManager in google.auth.transport._mtls_helper to track the active mTLS client certificate, private key, leaf certificate SHA-256 fingerprint, and generation counter across certificate reloads.

This is the first change in the gRPC mTLS client certificate rotation series. Subsequent PRs wire _MtlsCertManager into requests.Request and Compute Engine bound-token refreshes, AuthMetadataPlugin, google.api_core.grpc_helpers.create_channel, and gapic-generator so that TLS channel credentials and certificate-bound token refreshes stay on the same certificate generation.

This is PR1 from go/cert-rotation-python-auth phase 1.

Adds `_MtlsCertManager` in `google.auth.transport._mtls_helper` to track
the active mTLS client certificate, private key, leaf certificate
SHA-256 fingerprint, and generation counter across certificate reloads.

This is the first change in the gRPC mTLS client certificate rotation
series. Subsequent PRs wire `_MtlsCertManager` into `requests.Request`
and Compute Engine bound-token refreshes, `AuthMetadataPlugin`,
`google.api_core.grpc_helpers.create_channel`, and `gapic-generator` so
that TLS channel credentials and certificate-bound token refreshes stay
on the same certificate generation.
@nbayati
nbayati requested review from a team as code owners October 30, 2026 06:25

@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 _MtlsCertManager, a thread-safe manager for handling mTLS client certificates, tracking their generation, and managing reloading. It also adds comprehensive unit tests for this new class. The feedback suggests catching all standard exceptions (Exception) during certificate reloads rather than a specific subset, ensuring that unexpected errors from custom certificate sources do not crash the application and are instead gracefully handled as transient reload errors.

Comment on lines +1067 to +1083
except (
exceptions.ClientCertError,
exceptions.MutualTLSChannelError,
OSError,
ValueError,
) as caught_exc:
if self._generation_id > 0:
_LOGGER.debug(
"Failed to reload mTLS client certificate; retaining "
"previous certificate (generation %d): %s",
self._generation_id,
caught_exc,
)
return False
if isinstance(caught_exc, exceptions.ClientCertError):
raise
raise exceptions.ClientCertError(caught_exc) from caught_exc

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.

medium

The except block currently catches a specific list of exceptions (ClientCertError, MutualTLSChannelError, OSError, ValueError). However, when a custom client_cert_source callback is provided, it may raise other unexpected or custom exceptions (e.g., network errors, custom HSM/KMS exceptions, or even TypeError if the callback returns an unexpected format).

If such an exception is raised during a certificate reload (when self._generation_id > 0), it will propagate and crash the application/request instead of being gracefully handled as a transient reload error. To ensure maximum robustness and successfully retain the last known good certificate, we should catch all standard exceptions (Exception) during reload.

            except Exception as caught_exc:
                if self._generation_id > 0:
                    _LOGGER.debug(
                        "Failed to reload mTLS client certificate; retaining "
                        "previous certificate (generation %d): %s",
                        self._generation_id,
                        caught_exc,
                    )
                    return False
                if isinstance(caught_exc, exceptions.ClientCertError):
                    raise
                raise exceptions.ClientCertError(caught_exc) from caught_exc
References
  1. Do not replace historical graceful fallback behaviors (such as returning False/falling back to standard TLS) with exceptions if doing so would introduce breaking changes for downstream users and violate backwards compatibility.

@lsirac lsirac added the do not merge Indicates a pull request not ready for merge, due to either quality or timing. label Nov 7, 2026
@nbayati
nbayati requested a review from lsirac November 10, 2026 19:46
return hostname in _MTLS_EXACT_HOSTS or hostname.endswith(_MTLS_HOST_SUFFIXES)


class _MtlsCertManager:

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.

Should we re-export _MtlsCertManager from google.auth.transport.mtls here or in a follow-up PR so google-api-core doesn't have to import private _mtls_helper directly?

Comment on lines +1018 to +1019
with self._lock:
return self._cert, self._key, self._generation_id

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.

Does get_credential_snapshot() need to block on self._lock? While refresh() holds the lock across disk reads, mismatch retries, callbacks, or cert_provider_command, snapshot callers on other threads or an async event loop have to wait behind that I/O instead of reading an atomic snapshot tuple.

Comment on lines +1028 to +1031
has_cert, cert_bytes, key_bytes, _ = get_client_ssl_credentials(
generate_encrypted_key=False,
certificate_config_path=self._certificate_config_path,
)

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.

get_client_ssl_credentials falls back to ~/.secureConnect/context_aware_metadata.json whenever workload discovery returns (None, None). If an explicit certificate_config_path or workload config is briefly missing during rotation on a host with ~/.secureConnect, refresh() swaps to the SecureConnect cert instead of keeping the cached cert, and a missing explicit certificate_config_path on initial load returns False instead of raising ClientCertError. Should default discovery here also check GOOGLE_API_USE_CLIENT_CERTIFICATE?

Comment on lines +1040 to +1043
cert_obj = _agent_identity_utils.parse_certificate(cert_bytes)
fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(cert_obj)

return cert_bytes, key_bytes, fingerprint

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.

_load_cert_and_fingerprint parses cert_bytes here, but never parses key_bytes or checks that it matches cert_obj. #19043 only runs inside _read_cert_and_key_files, so client_cert_source callbacks, GKE credential bundles, and SecureConnect bypass it. Even on workload files with #19043, an unparseable or encrypted key makes _has_cert_key_mismatch return False, which overwrites the working snapshot and latches _cert_fingerprint to the new cert so _key stays broken after a valid key file arrives.

Comment on lines +1077 to +1083
except (
exceptions.ClientCertError,
exceptions.MutualTLSChannelError,
OSError,
ValueError,
ImportError,
) as caught_exc:

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.

Should this catch Exception instead of listing five exception types? cryptography.x509.InvalidVersion doesn't inherit from ValueError, malformed context_aware_metadata.json can raise TypeError or IndexError, and custom client_cert_source callbacks can raise RuntimeError or custom errors, all of which crash live reloads at generation_id > 0 and leave _completed_reload_id behind _reload_id. Watch out for the gemini-code-assist suggestion below too, since it's from an earlier commit and drops self._completed_reload_id = self._reload_id.

ImportError,
) as caught_exc:
if self._generation_id > 0:
_LOGGER.debug(

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.

Should suppressed reload failures log at warning instead of debug? Otherwise a broken cert path or permission error during rotation stays silent at default log levels until the cached cert expires hours later.

raise
raise exceptions.ClientCertError(caught_exc) from caught_exc

changed = cert_bytes is not None and fingerprint != self._cert_fingerprint

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.

Should changed check cert_bytes != self._cert or key_bytes != self._key instead of only the leaf fingerprint? Right now if a PEM bundle renews an intermediate CA while keeping the same leaf cert, or updates key_bytes for the same leaf, refresh() returns False and keeps serving the stale bytes.

assert manager.refresh() is False
assert manager.generation_id == 1

cert_file.write_bytes(OTHER_CERT_BYTES)

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.

Rotating only cert_file to OTHER_CERT_BYTES while leaving key_file untouched doesn't test that _key updates on rotation. Also other_cert.pem doesn't match privatekey.pem, so this test will fail once #19043 lands and _read_cert_and_key_files rejects mismatched pairs.

assert manager.refresh(expected_generation=0) is True
assert manager.generation_id == 1

reload_source = mock.Mock(side_effect=[reload_result] * 2)

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.

side_effect=[reload_result] * 2 only holds two items, and a second thread already consumes the second item on normal runs because the first thread bumps _reload_id before time.sleep(0.1). If any of the remaining four threads wakes up 100ms late after barrier.wait() on a busy CI runner, reload_source() raises StopIteration inside the worker thread and leaves results[idx] as None.

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

do not merge Indicates a pull request not ready for merge, due to either quality or timing.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants