Skip to content

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

Open
nbayati wants to merge 5 commits into
mainfrom
grpc_phase1_pr1
Open

nbayati wants to merge 5 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
if cert_bytes is None:
return False

if 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.

I think we are introducing the same edge case here as we have in the past in both Python and Java. We need to verify that the key matches the cert before storing the pair (and retry if it doesn't). Otherwise we can take a snapshot of when one is updated, and the other isn't, and then we would fail until the next rotation.

Prev PRs:

expected_generation is not None
and self._generation_id > expected_generation
):
return True

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.

refresh(expected_generation=...) only deduplicates concurrent callers when _generation_id advances. If concurrent requests fail with UNAUTHENTICATED or 401 while the cert on disk hasn't changed, or while a reload fails transiently, _generation_id stays equal to expected_generation and every waiting thread reloads from the cert source one after another under _lock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! I updated refresh() so threads waiting on the lock reuse the result of a reload that started after they arrived, including one that failed after startup and kept the old cert, instead of each reloading again. A burst of concurrent UNAUTHENTICATED/401 failures now causes at most two reloads: the one already in progress, plus one shared by the threads that arrived during it.


def __deepcopy__(self, memo):
# Share the same thread-safe manager instance across deep copies.
return self

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 also implement __copy__ returning self? With __getstate__ and __setstate__ defined below, copy.copy(manager) creates a separate manager with its own _lock and _generation_id instead of sharing the instance like copy.deepcopy(manager).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a05f953: added __copy__ returning self, so shallow and deep copies both share the same manager. The existing copy/pickle test now also asserts copy.copy(manager) is manager.

return self

def __getstate__(self):
state = self.__dict__.copy()

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.

__getstate__ keeps _client_cert_source in the state dict, so pickle.dumps() fails with AttributeError or PicklingError when client_cert_source is a closure like mtls.default_client_cert_source() or a lambda. Since GAPIC clients pass mtls.default_client_cert_source() by default, attaching this manager to Request will break pickle.dumps(request).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. The manager now raises TypeError when pickled, since I realized that it also holds the private key. The later PR that attaches it to Request will leave the manager out of the Request's pickled state, so the restored Request rediscovers mTLS like it does today.


expected_leaf_fp = _agent_identity_utils.calculate_certificate_fingerprint(
_agent_identity_utils.parse_certificate(TRUST_CHAIN_WITH_LEAF_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.

expected_leaf_fp runs calculate_certificate_fingerprint(parse_certificate(TRUST_CHAIN_WITH_LEAF_BYTES)), which is the exact same call _load_cert_and_fingerprint() makes internally, so assert manager._cert_fingerprint == expected_leaf_fp still passes even if parse_certificate hashes the root CA instead of the leaf cert pytest.public_cert_bytes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. The expected fingerprint is now computed from the leaf cert (public_cert.pem) loaded on its own, so the test fails if the manager hashes the root or the wrong block of the chain.


def worker(idx):
barrier.wait()
results[idx] = manager.refresh(expected_generation=1)

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.

Because the mock source returns from memory in ~0.1ms without blocking, the first thread finishes refresh() within CPython's 5ms switch interval before the other threads enter refresh(), so this test still passes even if self._lock is removed.

_, config_file = self._write_workload_config(tmp_path)

manager = _mtls_helper._MtlsCertManager(
certificate_config_path=str(config_file)

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.

This test monkeypatches _cloud_sdk.get_config_path above, yet passing certificate_config_path=str(config_file) here skips get_config_path completely and leaves happy-path load and reload with certificate_config_path=None untested across TestMtlsCertManager.

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