feat(auth): Implement python mtls helpers#17495
Conversation
There was a problem hiding this comment.
Code Review
This pull request removes the dependency on pyOpenSSL and cffi across the codebase, transitioning to the standard library ssl module and the cryptography library for mutual TLS (mTLS) support. It introduces a secure three-tier fallback strategy for handling client certificates and private keys via secure_cert_key_paths in _mtls_helper.py. Feedback on these changes includes addressing a potential NameError in urllib3.py due to an unimported module, utilizing contextlib.ExitStack instead of manual context manager calls to prevent resource leaks, and verifying write permissions for /dev/shm to avoid runtime crashes in restricted environments.
| bool: indicating whether the client certificate should be used for mTLS. | ||
| """ | ||
| return _mtls_helper.check_use_client_cert() | ||
|
|
There was a problem hiding this comment.
Do all of these new methods need to be public?
There was a problem hiding this comment.
3 of them need to be public. I made _load_client_cert_into_context private and decided to remove make_client_cert_ssl_context altogether.
macastelaz
left a comment
There was a problem hiding this comment.
The last commit, bc6d2b8, LGTM. I do also wonder if everything needs to be public or not though.
I am withholding approval on the PR as it currently contains much more than just this last commit.
|
|
||
|
|
||
| def load_client_cert_into_context( | ||
| ctx: ssl.SSLContext, |
There was a problem hiding this comment.
Statically annotating parameter ctx strictly as ssl.SSLContext contradicts the runtime duck-typing check at line 175 (hasattr(ctx, "load_cert_chain")). To align static typing with runtime duck typing, annotate ctx using typing.Protocol or Union[ssl.SSLContext, Any].
There was a problem hiding this comment.
Done. Updated the runtime check to strictly use isinstance(ctx, ssl.SSLContext) to align with the static typing.
| ctx.load_cert_chain( | ||
| certfile=cert_path, keyfile=key_path, password=passphrase_val | ||
| ) | ||
| except ( |
There was a problem hiding this comment.
The exception handler for ctx.load_cert_chain() omits TypeError. If cert_bytes, key_bytes, or passphrase is None (or an invalid type), Python's ssl module raises TypeError, which escapes uncaught instead of raising the documented MutualTLSChannelError.
There was a problem hiding this comment.
Done. Added TypeError to the caught exceptions.
| ValueError, | ||
| RuntimeError, | ||
| ) as caught_exc: | ||
| new_exc = exceptions.MutualTLSChannelError( |
There was a problem hiding this comment.
Raising MutualTLSChannelError with a static string ("Failed to load client certificate and key for mTLS.") instead of passing the underlying caught_exc is inconsistent with lines 75 and 120, losing low-level OpenSSL failure specifics when exception messages are logged without tracebacks.
There was a problem hiding this comment.
Done. Updated to pass the underlying exception directly to MutualTLSChannelError.
| cert_bytes, | ||
| key_bytes, | ||
| passphrase, | ||
| ) = _mtls_helper.get_client_ssl_credentials() |
There was a problem hiding this comment.
Invoking _mtls_helper.get_client_ssl_credentials() in load_default_client_cert without exception handling allows raw OSError, RuntimeError, and ValueError exceptions to escape out of get_default_ssl_context() (line 277), violating the function's documented contract.
There was a problem hiding this comment.
Done. Wrapped the call in a try/except block to catch and raise them as a MutualTLSChannelError.
| ("invalid_val", False, False), | ||
| ], | ||
| ) | ||
| @mock.patch( |
There was a problem hiding this comment.
In tests evaluating environment variable lookups, the code patches the module string constant GOOGLE_API_USE_MTLS_ENDPOINT with its own literal string value ("GOOGLE_API_USE_MTLS_ENDPOINT"), which is redundant boilerplate.
There was a problem hiding this comment.
Done. Removed the redundant mock.
| @mock.patch.object(os, "memfd_create", create=True) | ||
| @mock.patch.object(_mtls_helper, "_memfd_cert_key_paths", autospec=True) | ||
| @mock.patch.object(_mtls_helper, "_tempfile_cert_key_paths", autospec=True) | ||
| def test_tier2_fallback_to_tier3_on_oserror( |
There was a problem hiding this comment.
While test_tier2_fallback_to_tier3_on_oserror verifies fallback when RAM descriptors fail, the test suite lacks a negative test case verifying exception propagation when Tier 3 disk fallback (_tempfile_cert_key_paths) fails with OSError or ValueError.
There was a problem hiding this comment.
Done. Added test_tier3_fallback_failure_propagation to verify exception propagation when Tier 3 fails.
| mock_close.assert_called_once_with(15) | ||
|
|
||
|
|
||
| class TestTempfileCertKeyPaths(object): |
There was a problem hiding this comment.
In TestTempfileCertKeyPaths, no test verifies that an exception raised during key_path creation or writing triggers cleanup of the already-created cert_path file in the context manager's error handler.
There was a problem hiding this comment.
Done. Added a test case to verify that os.remove is called on the cert file if an exception occurs before the key file finishes.
| decrypted = serialization.load_pem_private_key( | ||
| encrypted_bytes, password=b"my_passphrase" | ||
| ) | ||
| assert decrypted |
There was a problem hiding this comment.
In test_encrypt_key_if_plaintext, assert decrypted performs a weak truthy check on the loaded private key object rather than serializing and comparing key parameters against pytest.private_key_bytes to verify exact key equality.
There was a problem hiding this comment.
Done. Updated to serialize the decrypted key object back to bytes and use an equality assertion (==) against the original plaintext key for exact verification.
| @mock.patch("builtins.open", autospec=True) | ||
| @mock.patch.object(os, "fsync") | ||
| @mock.patch.object(os, "remove") | ||
| def test_success( |
There was a problem hiding this comment.
In TestSecureWipeAndRemove, the test verifies write(), fsync(), and remove(), but never asserts mock_fh.flush.assert_called_once(), even though _secure_wipe_and_remove explicitly calls f.flush() before os.fsync().
There was a problem hiding this comment.
Done. Added the missing assertion for mock_fh.flush.
…ndwritten SDK mTLS support - Introduced `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable to control whether an mTLS endpoint should be used (`always`, `never`, or `auto`). - Added several new helper functions in `google.auth.transport.mtls` to facilitate SSL context creation and client certificate loading: - `load_client_cert_into_context`: Loads a client certificate and key into a provided SSL context. - `make_client_cert_ssl_context`: Creates a default SSL context loaded with a specific client certificate and key. - `load_default_client_cert`: Discovers and loads the default client certificate into a provided SSL context if mTLS is enabled. - `get_default_ssl_context`: Returns a default SSL context pre-loaded with the default client certificate, or `None` if unavailable. - `should_use_mtls_endpoint`: Determines if an mTLS endpoint should be used based on the new environment variable and certificate availability. - Fixed outdated docstrings for `default_client_cert_source` and `default_client_encrypted_cert_source` to correctly state they raise `MutualTLSChannelError` instead of `DefaultClientCertSourceError`. - Updated `default_client_cert_source` to also catch `ClientCertError` when loading credentials. - Added comprehensive unit tests for the new mTLS helper methods.
bc6d2b8 to
782211c
Compare
Removes the private `_make_client_cert_ssl_context` method and its corresponding unit test from `google.auth.transport.mtls`. Initially, this method was added to mirror the async API (`aio.transport.mtls.make_client_cert_ssl_context`). However, upon further review, we decided that external client libraries do not need it (they should use `get_default_ssl_context` or `load_default_client_cert` instead), and there is no need for it internally within the synchronous transports (e.g., `requests`, `urllib3`) which handle their mTLS context initialization independently. Removing this dead code reduces the internal API surface area and eliminates unnecessary maintenance overhead.
This PR builds on top of PR #16976. Once that PR is merged to main, this PR will be rebased and would only have the commit that introduces the helper methods (commit bc6d2b8), as the commits before this one are all from the other PR.
This PR provides helper methods to allow custom HTTP and WebSocket connection pools (such as those in google-genai and google-adk) to load default client certificates and resolve the GOOGLE_API_USE_MTLS_ENDPOINT env var. Changes include:
GOOGLE_API_USE_MTLS_ENDPOINTenvironment variable to control whether an mTLS endpoint should be used (always,never, orauto).google.auth.transport.mtlsto facilitate SSL context creation and client certificate loading:load_client_cert_into_context: Loads a client certificate and key into a provided SSL context.make_client_cert_ssl_context: Creates a default SSL context loaded with a specific client certificate and key.load_default_client_cert: Discovers and loads the default client certificate into a provided SSL context if mTLS is enabled.get_default_ssl_context: Returns a default SSL context pre-loaded with the default client certificate, orNoneif unavailable.should_use_mtls_endpoint: Determines if an mTLS endpoint should be used based on the new environment variable and certificate availability.default_client_cert_sourceanddefault_client_encrypted_cert_sourceto correctly state they raiseMutualTLSChannelErrorinstead ofDefaultClientCertSourceError.default_client_cert_sourceto also catchClientCertErrorwhen loading credentials.