Repository navigation
Conversation
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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_excReferences
- 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.
…and cert source callback out of pickles
… lock and it fails without it
| return hostname in _MTLS_EXACT_HOSTS or hostname.endswith(_MTLS_HOST_SUFFIXES) | ||
|
|
||
|
|
||
| class _MtlsCertManager: |
There was a problem hiding this comment.
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?
| with self._lock: | ||
| return self._cert, self._key, self._generation_id |
There was a problem hiding this comment.
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.
| has_cert, cert_bytes, key_bytes, _ = get_client_ssl_credentials( | ||
| generate_encrypted_key=False, | ||
| certificate_config_path=self._certificate_config_path, | ||
| ) |
There was a problem hiding this comment.
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?
| cert_obj = _agent_identity_utils.parse_certificate(cert_bytes) | ||
| fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(cert_obj) | ||
|
|
||
| return cert_bytes, key_bytes, fingerprint |
There was a problem hiding this comment.
_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.
| except ( | ||
| exceptions.ClientCertError, | ||
| exceptions.MutualTLSChannelError, | ||
| OSError, | ||
| ValueError, | ||
| ImportError, | ||
| ) as caught_exc: |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
Adds
_MtlsCertManageringoogle.auth.transport._mtls_helperto 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
_MtlsCertManagerintorequests.Requestand Compute Engine bound-token refreshes,AuthMetadataPlugin,google.api_core.grpc_helpers.create_channel, andgapic-generatorso 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.