From baa87d9b3c02ab41749968a3213048a8fa0f8cf8 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 6 May 2026 21:15:08 -0700 Subject: [PATCH 01/25] refactor(auth): replace pyOpenSSL with standard ssl and cryptography Replace pyOpenSSL with standard library ssl for mTLS transport and update key decryption to use cryptography library. This change also enhances security for handling private keys by: - Using Linux memfd_create for RAM-backed in-memory files to avoid writing secrets to physical storage. - Encrypting plaintext keys on-the-fly before writing to fallback temporary files on disk. - Securely wiping temporary files with null bytes before deletion. --- .../google/auth/aio/transport/mtls.py | 34 +-- .../google-auth/google/auth/identity_pool.py | 26 +- .../auth/transport/_custom_tls_signer.py | 9 +- .../google/auth/transport/_mtls_helper.py | 238 +++++++++++++++++- .../google/auth/transport/requests.py | 48 ++-- .../google/auth/transport/urllib3.py | 32 +-- packages/google-auth/noxfile.py | 1 - packages/google-auth/setup.py | 11 +- packages/google-auth/system_tests/noxfile.py | 2 +- .../google-auth/tests/test_identity_pool.py | 13 +- .../transport/test__custom_tls_signer.py | 6 - .../tests/transport/test__mtls_helper.py | 31 +-- .../tests/transport/test_aio_mtls_helper.py | 18 -- .../tests/transport/test_requests.py | 38 ++- .../tests/transport/test_urllib3.py | 42 +++- 15 files changed, 368 insertions(+), 181 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/mtls.py b/packages/google-auth/google/auth/aio/transport/mtls.py index b85d30b53485..aee96ccefacb 100644 --- a/packages/google-auth/google/auth/aio/transport/mtls.py +++ b/packages/google-auth/google/auth/aio/transport/mtls.py @@ -25,34 +25,12 @@ from typing import Optional from google.auth import exceptions -import google.auth.transport._mtls_helper import google.auth.transport.mtls +from google.auth.transport._mtls_helper import secure_cert_key_paths _LOGGER = logging.getLogger(__name__) -@contextlib.contextmanager -def _create_temp_file(content: bytes): - """Creates a temporary file with the given content. - - Args: - content (bytes): The content to write to the file. - - Yields: - str: The path to the temporary file. - """ - # Create a temporary file that is readable only by the owner. - fd, file_path = tempfile.mkstemp() - try: - with os.fdopen(fd, "wb") as f: - f.write(content) - yield file_path - finally: - # Securely delete the file after use. - if os.path.exists(file_path): - os.remove(file_path) - - def make_client_cert_ssl_context( cert_bytes: bytes, key_bytes: bytes, passphrase: Optional[bytes] = None ) -> ssl.SSLContext: @@ -71,13 +49,15 @@ def make_client_cert_ssl_context( Raises: google.auth.exceptions.TransportError: If there is an error loading the certificate. """ - with _create_temp_file(cert_bytes) as cert_path, _create_temp_file( - key_bytes - ) as key_path: + with secure_cert_key_paths(cert_bytes, key_bytes, passphrase=passphrase) as ( + cert_path, + key_path, + passphrase_val, + ): try: context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) context.load_cert_chain( - certfile=cert_path, keyfile=key_path, password=passphrase + certfile=cert_path, keyfile=key_path, password=passphrase_val ) return context except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 333f7bdf53ea..455065590a24 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -152,13 +152,9 @@ def __init__(self, trust_chain_path, leaf_cert_callback): @_helpers.copy_docstring(SubjectTokenSupplier) def get_subject_token(self, context, request): - # Import OpennSSL inline because it is an extra import only required by customers - # using mTLS. - from OpenSSL import crypto + from cryptography import x509 - leaf_cert = crypto.load_certificate( - crypto.FILETYPE_PEM, self._leaf_cert_callback() - ) + leaf_cert = x509.load_pem_x509_certificate(self._leaf_cert_callback()) trust_chain = self._read_trust_chain() cert_chain = [] @@ -184,9 +180,7 @@ def get_subject_token(self, context, request): return json.dumps(cert_chain) def _read_trust_chain(self): - # Import OpennSSL inline because it is an extra import only required by customers - # using mTLS. - from OpenSSL import crypto + from cryptography import x509 certificate_trust_chain = [] # If no trust chain path was provided, return an empty list. @@ -204,9 +198,7 @@ def _read_trust_chain(self): cert_data = b"-----BEGIN CERTIFICATE-----" + cert_block try: # Load each certificate and add it to the trust chain. - cert = crypto.load_certificate( - crypto.FILETYPE_PEM, cert_data - ) + cert = x509.load_pem_x509_certificate(cert_data) certificate_trust_chain.append(cert) except Exception as e: raise exceptions.RefreshError( @@ -221,13 +213,11 @@ def _read_trust_chain(self): ) def _encode_cert(cert): - # Import OpennSSL inline because it is an extra import only required by customers - # using mTLS. - from OpenSSL import crypto + from cryptography.hazmat.primitives import serialization - return base64.b64encode( - crypto.dump_certificate(crypto.FILETYPE_ASN1, cert) - ).decode("utf-8") + return base64.b64encode(cert.public_bytes(serialization.Encoding.DER)).decode( + "utf-8" + ) def _parse_token_data(token_content, format_type="text", subject_token_field_name=None): diff --git a/packages/google-auth/google/auth/transport/_custom_tls_signer.py b/packages/google-auth/google/auth/transport/_custom_tls_signer.py index 9279158d45c6..1ac0d081e2da 100644 --- a/packages/google-auth/google/auth/transport/_custom_tls_signer.py +++ b/packages/google-auth/google/auth/transport/_custom_tls_signer.py @@ -23,8 +23,6 @@ import os import sys -import cffi # type: ignore - from google.auth import exceptions _LOGGER = logging.getLogger(__name__) @@ -45,11 +43,6 @@ ) -# Cast SSL_CTX* to void* -def _cast_ssl_ctx_to_void_p_pyopenssl(ssl_ctx): - return ctypes.cast(int(cffi.FFI().cast("intptr_t", ssl_ctx)), ctypes.c_void_p) - - # Cast SSL_CTX* to void* def _cast_ssl_ctx_to_void_p_stdlib(context): return ctypes.c_void_p.from_address( @@ -274,7 +267,7 @@ def attach_to_ssl_context(self, ctx): if not self._offload_lib.ConfigureSslContext( self._sign_callback, ctypes.c_char_p(self._cert), - _cast_ssl_ctx_to_void_p_pyopenssl(ctx._ctx._context), + _cast_ssl_ctx_to_void_p_stdlib(ctx), ): raise exceptions.MutualTLSChannelError( "failed to configure ECP Offload SSL context" diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 803dd71207f2..1bb2219580f7 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -14,11 +14,13 @@ """Helper functions for getting mTLS cert and key.""" +import contextlib import json import logging from os import environ, getenv, path import re import subprocess +from typing import Generator, Optional, Tuple, Union from google.auth import _agent_identity_utils from google.auth import environment_vars @@ -71,6 +73,229 @@ ) +@contextlib.contextmanager +def secure_cert_key_paths( + cert: Union[str, bytes], + key: Union[str, bytes], + passphrase: Optional[bytes] = None, +) -> Generator[Tuple[str, str, Optional[bytes]], None, None]: + """Provides secure file paths for certificate and key. + + Standard TLS libraries (like Python's standard library `ssl`) require file paths to + load credentials. To minimize exposure of raw private key bytes on physical storage, + this context manager implements a three-tier fallback strategy: yielding pass-through + paths (Tier 1), using RAM-backed virtual files on Linux (Tier 2), or falling back + to encrypted temporary files on disk (Tier 3). + + Args: + cert (Union[str, bytes]): Certificate path or raw PEM content bytes. + key (Union[str, bytes]): Private key path or raw PEM content bytes. + passphrase (Optional[bytes]): Optional passphrase for the private key. + + Yields: + Tuple[str, str, Optional[bytes]]: The certificate path, key path, and + the passphrase needed to load the key (either the user's original, + or the newly generated one if Tier 3 had to encrypt the key). + """ + import os + import sys + + # Tier 1: Pass-through (No-op). If the caller already provided file paths, + # we yield them directly to avoid any unnecessary file creation. + if isinstance(cert, str) and isinstance(key, str): + yield cert, key, passphrase + return + + cert_bytes = cert if isinstance(cert, bytes) else None + key_bytes = key if isinstance(key, bytes) else None + + # Tier 2: Linux RAM-backed virtual files. If supported by the OS, we write + # the bytes to anonymous in-memory files using memfd_create. This yields + # /proc/self/fd/... paths, keeping the private key entirely in memory. + if sys.platform == "linux" and hasattr(os, "memfd_create"): + cm = _memfd_cert_key_paths(cert_bytes, key_bytes) + try: + cert_path, key_path = cm.__enter__() + except OSError: + pass # Fallback to Tier 3 on failure. + else: + try: + # Handle cases where path exists but might be restricted. + if (cert_path is None or os.path.exists(cert_path)) and ( + key_path is None or os.path.exists(key_path) + ): + yield cert_path or cert, key_path or key, passphrase + return + finally: + import sys + + exc_info = sys.exc_info() + cm.__exit__( + *(exc_info if exc_info[0] is not None else (None, None, None)) + ) + # If verification failed, fall through to Tier 3. + + # Tier 3: Fallback Encrypted Temp Files. If in-memory files are not supported + # (macOS/Windows), we write to disk. To protect the key, we encrypt plaintext + # keys on-the-fly and securely wipe the files with null bytes during cleanup. + with _tempfile_cert_key_paths(cert_bytes, key_bytes, passphrase) as ( + cert_path, + key_path, + new_passphrase, + ): + yield cert_path or cert, key_path or key, new_passphrase + + +def _encrypt_key_if_plaintext( + key_bytes: bytes, passphrase: Optional[bytes] +) -> Tuple[bytes, Optional[bytes]]: + """Encrypts a plaintext PEM key if necessary, returning the bytes and passphrase. + + If the key is already encrypted, returns it as-is. + """ + from cryptography.hazmat.primitives import serialization + import secrets + + try: + pkey = serialization.load_pem_private_key(key_bytes, password=None) + # It's plaintext, encrypt it. + target_passphrase = ( + passphrase + if passphrase is not None + else secrets.token_hex(32).encode("utf-8") + ) + encrypted_content = pkey.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption( + target_passphrase + ), + ) + return encrypted_content, target_passphrase + except (ValueError, TypeError): + # Likely already encrypted or invalid, return as-is. + return key_bytes, passphrase + + +def _secure_wipe_and_remove(file_path: str): + """Overwrites a file with null bytes before deleting it. + + This is an extra security measure to make file recovery harder. However, on modern + solid-state drives (SSDs), the hardware optimizes where data is written, meaning + the original private key bytes might still physically remain on the storage chips + until the drive cleans them up. + """ + import os + + if not os.path.exists(file_path): + return + try: + size = os.path.getsize(file_path) + with open(file_path, "r+b") as f: + f.write(b"\0" * size) + f.flush() + os.fsync(f.fileno()) + except OSError: + pass # Ignore permission/lock errors during cleanup. + finally: + try: + os.remove(file_path) + except OSError: + pass + + +@contextlib.contextmanager +def _memfd_cert_key_paths(cert_bytes: Optional[bytes], key_bytes: Optional[bytes]): + """Creates secure, in-memory virtual files on Linux using memfd_create. + + Yields: + Tuple[Optional[str], Optional[str]]: In-memory file paths pointing to + the active descriptors (e.g., '/proc/self/fd/3'). + """ + import os + + cleanup_fds = [] + cert_path, key_path = None, None + + try: + if cert_bytes is not None: + # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. + fd_cert = os.memfd_create("mtls_cert", os.MFD_CLOEXEC) + os.write(fd_cert, cert_bytes) + cert_path = f"/proc/self/fd/{fd_cert}" + cleanup_fds.append(fd_cert) + + if key_bytes is not None: + fd_key = os.memfd_create("mtls_key", os.MFD_CLOEXEC) + os.write(fd_key, key_bytes) + key_path = f"/proc/self/fd/{fd_key}" + cleanup_fds.append(fd_key) + + yield cert_path, key_path + finally: + # Closing the descriptors automatically frees the RAM allocation. + for fd in cleanup_fds: + try: + os.close(fd) + except OSError: + pass + + +@contextlib.contextmanager +def _tempfile_cert_key_paths( + cert_bytes: Optional[bytes], key_bytes: Optional[bytes], passphrase: Optional[bytes] +): + """Creates secure temporary file paths on disk, encrypting private keys. + + Yields: + Tuple[Optional[str], Optional[str], Optional[bytes]]: The temporary file + paths and the passphrase needed to load the key. + """ + import os + import tempfile + + # Prioritize RAM-backed /dev/shm to avoid writing secrets to physical storage. + tmp_dir = "/dev/shm" if os.path.isdir("/dev/shm") else None + cert_path, key_path = None, None + cleanup_files = [] + new_passphrase = passphrase + + try: + if cert_bytes is not None: + fd, cert_path = tempfile.mkstemp(dir=tmp_dir) + cleanup_files.append(cert_path) + with os.fdopen(fd, "wb") as f: + f.write(cert_bytes) + f.flush() + os.fsync(f.fileno()) + + if key_bytes is not None: + # Encrypt plaintext keys on-the-fly before dropping to disk. + encrypted_key_bytes, new_passphrase = _encrypt_key_if_plaintext( + key_bytes, passphrase + ) + + fd, key_path = tempfile.mkstemp(dir=tmp_dir) + cleanup_files.append(key_path) + with os.fdopen(fd, "wb") as f: + f.write(encrypted_key_bytes) + f.flush() + os.fsync(f.fileno()) + + yield cert_path, key_path, new_passphrase + finally: + for file_path in cleanup_files: + try: + # Wiping the private key with null bytes before removal. + if file_path == key_path: + _secure_wipe_and_remove(file_path) + else: + if os.path.exists(file_path): + os.remove(file_path) + except OSError: + pass + + def _check_config_path(config_path): """Checks for config file path. If it exists, returns the absolute path with user expansion; otherwise returns None. @@ -443,16 +668,19 @@ def client_cert_callback(): bytes: The decrypted private key in PEM format. Raises: - ImportError: If pyOpenSSL is not installed. - OpenSSL.crypto.Error: If there is any problem decrypting the private key. + ValueError: If there is any problem decrypting the private key. """ - from OpenSSL import crypto + from cryptography.hazmat.primitives import serialization # First convert encrypted_key_bytes to PKey object - pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key, passphrase=passphrase) + pkey = serialization.load_pem_private_key(key, password=passphrase) # Then dump the decrypted key bytes - return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) + return pkey.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) def _check_use_client_cert_env(): diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index b9c308903213..32b3cf738c25 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -204,30 +204,37 @@ class _MutualTlsAdapter(requests.adapters.HTTPAdapter): key (bytes): client private key in PEM format Raises: - ImportError: if certifi or pyOpenSSL is not installed - OpenSSL.crypto.Error: if client cert or key is invalid + ImportError: if certifi is not installed """ def __init__(self, cert, key): import certifi - from OpenSSL import crypto - import urllib3.contrib.pyopenssl # type: ignore - - urllib3.contrib.pyopenssl.inject_into_urllib3() - - pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key) - x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert) + import ssl ctx_poolmanager = create_urllib3_context() ctx_poolmanager.load_verify_locations(cafile=certifi.where()) - ctx_poolmanager._ctx.use_certificate(x509) - ctx_poolmanager._ctx.use_privatekey(pkey) - self._ctx_poolmanager = ctx_poolmanager ctx_proxymanager = create_urllib3_context() ctx_proxymanager.load_verify_locations(cafile=certifi.where()) - ctx_proxymanager._ctx.use_certificate(x509) - ctx_proxymanager._ctx.use_privatekey(pkey) + + with _mtls_helper.secure_cert_key_paths(cert, key) as ( + cert_path, + key_path, + passphrase, + ): + try: + ctx_poolmanager.load_cert_chain( + certfile=cert_path, keyfile=key_path, password=passphrase + ) + ctx_proxymanager.load_cert_chain( + certfile=cert_path, keyfile=key_path, password=passphrase + ) + except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: + raise exceptions.MutualTLSChannelError( + "Failed to configure client certificate and key for mTLS." + ) from exc + + self._ctx_poolmanager = ctx_poolmanager self._ctx_proxymanager = ctx_proxymanager super(_MutualTlsAdapter, self).__init__() @@ -258,7 +265,7 @@ class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter): } Raises: - ImportError: if certifi or pyOpenSSL is not installed + ImportError: if certifi is not installed google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel creation failed for any reason. """ @@ -270,10 +277,6 @@ def __init__(self, enterprise_cert_file_path): self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path) self.signer.load_libraries() - import urllib3.contrib.pyopenssl - - urllib3.contrib.pyopenssl.inject_into_urllib3() - poolmanager = create_urllib3_context() poolmanager.load_verify_locations(cafile=certifi.where()) self.signer.attach_to_ssl_context(poolmanager) @@ -450,11 +453,6 @@ def configure_mtls_channel(self, client_cert_callback=None): if not use_client_cert: return - try: - import OpenSSL - except ImportError as caught_exc: - new_exc = exceptions.MutualTLSChannelError(caught_exc) - raise new_exc from caught_exc try: ( @@ -473,7 +471,7 @@ def configure_mtls_channel(self, client_cert_callback=None): exceptions.ClientCertError, ImportError, OSError, - OpenSSL.crypto.Error, + ValueError, ) as caught_exc: new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index 0a1de5f86f7c..d4889e511871 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -174,22 +174,27 @@ def _make_mutual_tls_http(cert, key): urllib3.PoolManager: Mutual TLS HTTP connection. Raises: - ImportError: If certifi or pyOpenSSL is not installed. - OpenSSL.crypto.Error: If the cert or key is invalid. + ValueError: If the cert or key is invalid. """ import certifi - from OpenSSL import crypto - import urllib3.contrib.pyopenssl # type: ignore + import ssl - urllib3.contrib.pyopenssl.inject_into_urllib3() ctx = urllib3.util.ssl_.create_urllib3_context() ctx.load_verify_locations(cafile=certifi.where()) - pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key) - x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert) - - ctx._ctx.use_certificate(x509) - ctx._ctx.use_privatekey(pkey) + with _mtls_helper.secure_cert_key_paths(cert, key) as ( + cert_path, + key_path, + passphrase, + ): + try: + ctx.load_cert_chain( + certfile=cert_path, keyfile=key_path, password=passphrase + ) + except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: + raise exceptions.MutualTLSChannelError( + "Failed to configure client certificate and key for mTLS." + ) from exc http = urllib3.PoolManager(ssl_context=ctx) return http @@ -339,11 +344,6 @@ def configure_mtls_channel(self, client_cert_callback=None): if not use_client_cert: return False - try: - import OpenSSL - except ImportError as caught_exc: - new_exc = exceptions.MutualTLSChannelError(caught_exc) - raise new_exc from caught_exc try: found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key( @@ -360,7 +360,7 @@ def configure_mtls_channel(self, client_cert_callback=None): exceptions.ClientCertError, ImportError, OSError, - OpenSSL.crypto.Error, + ValueError, ) as caught_exc: new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc diff --git a/packages/google-auth/noxfile.py b/packages/google-auth/noxfile.py index 5962f96bf094..752d719ebcf5 100644 --- a/packages/google-auth/noxfile.py +++ b/packages/google-auth/noxfile.py @@ -150,7 +150,6 @@ def mypy(session): "mypy", "types-certifi", "types-freezegun", - "types-pyOpenSSL", "types-requests", "types-setuptools", "types-mock", diff --git a/packages/google-auth/setup.py b/packages/google-auth/setup.py index cf3148130d6e..85902dbb32ce 100644 --- a/packages/google-auth/setup.py +++ b/packages/google-auth/setup.py @@ -35,10 +35,7 @@ reauth_extra_require = ["pyu2f>=0.1.5"] -# TODO(https://github.com/googleapis/google-auth-library-python/issues/1738): Add bounds for pyopenssl dependency. -enterprise_cert_extra_require = ["pyopenssl"] - -pyopenssl_extra_require = ["pyopenssl>=20.0.0"] +enterprise_cert_extra_require = cryptography_base_require # TODO(https://github.com/googleapis/google-auth-library-python/issues/1739): Add bounds for urllib3 and packaging dependencies. urllib3_extra_require = ["urllib3", "packaging"] @@ -55,7 +52,6 @@ "pytest", "pytest-cov", "pytest-localserver", - *pyopenssl_extra_require, *reauth_extra_require, "responses", *urllib3_extra_require, @@ -63,10 +59,6 @@ *aiohttp_extra_require, "aioresponses", "pytest-asyncio", - # TODO(https://github.com/googleapis/google-auth-library-python/issues/1665): Remove the pinned version of pyopenssl - # once `TestDecryptPrivateKey::test_success` is updated to remove the deprecated `OpenSSL.crypto.sign` and - # `OpenSSL.crypto.verify` methods. See: https://www.pyopenssl.org/en/latest/changelog.html#id3. - "pyopenssl < 24.3.0", # TODO(https://github.com/googleapis/google-auth-library-python/issues/1722): `test_aiohttp_requests` depend on # aiohttp < 3.10.0 which is a bug. Investigate and remove the pinned aiohttp version. "aiohttp < 3.10.0", @@ -77,7 +69,6 @@ "cryptography": cryptography_base_require, "aiohttp": aiohttp_extra_require, "enterprise_cert": enterprise_cert_extra_require, - "pyopenssl": pyopenssl_extra_require, "pyjwt": pyjwt_extra_require, "reauth": reauth_extra_require, "requests": requests_extra_require, diff --git a/packages/google-auth/system_tests/noxfile.py b/packages/google-auth/system_tests/noxfile.py index 2cc4d122cf02..825ef0aab509 100644 --- a/packages/google-auth/system_tests/noxfile.py +++ b/packages/google-auth/system_tests/noxfile.py @@ -322,7 +322,7 @@ def urllib3(session): @nox.session(python=PYTHON_VERSIONS_SYNC) def mtls_http(session): session.install(LIBRARY_DIR) - session.install(*TEST_DEPENDENCIES_SYNC, "pyopenssl") + session.install(*TEST_DEPENDENCIES_SYNC) session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE default( session, diff --git a/packages/google-auth/tests/test_identity_pool.py b/packages/google-auth/tests/test_identity_pool.py index 4b3349028b54..92659bd90b38 100644 --- a/packages/google-auth/tests/test_identity_pool.py +++ b/packages/google-auth/tests/test_identity_pool.py @@ -20,7 +20,8 @@ from unittest import mock import urllib -from OpenSSL import crypto +from cryptography import x509 +from cryptography.hazmat.primitives import serialization import pytest # type: ignore from google.auth import _helpers, external_account @@ -69,17 +70,15 @@ JSON_FILE_SUBJECT_TOKEN = JSON_FILE_CONTENT.get(SUBJECT_TOKEN_FIELD_NAME) with open(CERT_FILE, "rb") as f: + cert = x509.load_pem_x509_certificate(f.read()) CERT_FILE_CONTENT = base64.b64encode( - crypto.dump_certificate( - crypto.FILETYPE_ASN1, crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) - ) + cert.public_bytes(serialization.Encoding.DER) ).decode("utf-8") with open(OTHER_CERT_FILE, "rb") as f: + cert = x509.load_pem_x509_certificate(f.read()) OTHER_CERT_FILE_CONTENT = base64.b64encode( - crypto.dump_certificate( - crypto.FILETYPE_ASN1, crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) - ) + cert.public_bytes(serialization.Encoding.DER) ).decode("utf-8") TOKEN_URL = "https://sts.googleapis.com/v1/token" diff --git a/packages/google-auth/tests/transport/test__custom_tls_signer.py b/packages/google-auth/tests/transport/test__custom_tls_signer.py index 3ecb29a60516..c0e40466e17e 100644 --- a/packages/google-auth/tests/transport/test__custom_tls_signer.py +++ b/packages/google-auth/tests/transport/test__custom_tls_signer.py @@ -22,12 +22,6 @@ from google.auth import exceptions from google.auth.transport import _custom_tls_signer -urllib3_pyopenssl = pytest.importorskip( - "urllib3.contrib.pyopenssl", - reason="urllib3.contrib.pyopenssl not available in this environment", -) - -urllib3_pyopenssl.inject_into_urllib3() FAKE_ENTERPRISE_CERT_FILE_PATH = "/path/to/enterprise/cert/file" ENTERPRISE_CERT_FILE = os.path.join( diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 0fe5067352a1..a87d52c3bfac 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -16,7 +16,9 @@ import re from unittest import mock -from OpenSSL import crypto +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import hashes import pytest # type: ignore from google.auth import environment_vars, exceptions @@ -26,16 +28,17 @@ KEY_MOCK_VAL = b"key" CONTEXT_AWARE_METADATA = {"cert_provider_command": ["some command"]} ENCRYPTED_EC_PRIVATE_KEY = b"""-----BEGIN ENCRYPTED PRIVATE KEY----- -MIHkME8GCSqGSIb3DQEFDTBCMCkGCSqGSIb3DQEFDDAcBAgl2/yVgs1h3QICCAAw -DAYIKoZIhvcNAgkFADAVBgkrBgEEAZdVAQIECJk2GRrvxOaJBIGQXIBnMU4wmciT -uA6yD8q0FxuIzjG7E2S6tc5VRgSbhRB00eBO3jWmO2pBybeQW+zVioDcn50zp2ts -wYErWC+LCm1Zg3r+EGnT1E1GgNoODbVQ3AEHlKh1CGCYhEovxtn3G+Fjh7xOBrNB -saVVeDb4tHD4tMkiVVUBrUcTZPndP73CtgyGHYEphasYPzEz3+AU +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBClWcQyUELNC9Hjr+Sp +WK85AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQ6uJeoqE7P9HtxAgS +n6rBFgSBkMRDYXLucNp7ew7LbQmkZCmjnRhgyw6b0dD3eK8f3jisj8UiR8aj9a2S +1FZiNHKLmI7hkZHH+d2DPWYhe/tf5SS4iLzpZogBehMv4UDNnNaj0dvQZgpnpciK +1H+0u/i+crc1WAGlemLAi7dktCCBTzeX19cRMGHie68rx1C82LHLZmefr7AEIVxp +uUoJ+sLhBw== -----END ENCRYPTED PRIVATE KEY-----""" EC_PUBLIC_KEY = b"""-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvCNi1NoDY1oMqPHIgXI8RBbTYGi/ -brEjbre1nSiQW11xRTJbVeETdsuP0EAu2tG3PcRhhwDfeJ8zXREgTBurNw== +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwdsHzL05VUmqYJat2yGdbSHQAg49 +Wc+fhwLH3b+SCC/2/TqPNDy9yMdMxMtEfZfKal2EaeE2erJrtu7WNfjD0Q== -----END PUBLIC KEY-----""" PASSPHRASE = b"""-----BEGIN PASSPHRASE----- @@ -755,17 +758,15 @@ def test_success(self): decrypted_key = _mtls_helper.decrypt_private_key( ENCRYPTED_EC_PRIVATE_KEY, PASSPHRASE_VALUE ) - private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, decrypted_key) - public_key = crypto.load_publickey(crypto.FILETYPE_PEM, EC_PUBLIC_KEY) - x509 = crypto.X509() - x509.set_pubkey(public_key) + private_key = serialization.load_pem_private_key(decrypted_key, password=None) + public_key = serialization.load_pem_public_key(EC_PUBLIC_KEY) # Test the decrypted key works by signing and verification. - signature = crypto.sign(private_key, b"data", "sha256") - crypto.verify(x509, signature, b"data", "sha256") + signature = private_key.sign(b"data", ec.ECDSA(hashes.SHA256())) + public_key.verify(signature, b"data", ec.ECDSA(hashes.SHA256())) def test_crypto_error(self): - with pytest.raises(crypto.Error): + with pytest.raises(ValueError): _mtls_helper.decrypt_private_key( ENCRYPTED_EC_PRIVATE_KEY, b"wrong_password" ) diff --git a/packages/google-auth/tests/transport/test_aio_mtls_helper.py b/packages/google-auth/tests/transport/test_aio_mtls_helper.py index bc9cde7d793b..2af155d9ee83 100644 --- a/packages/google-auth/tests/transport/test_aio_mtls_helper.py +++ b/packages/google-auth/tests/transport/test_aio_mtls_helper.py @@ -26,24 +26,6 @@ class TestMTLS: - @pytest.mark.asyncio - async def test__create_temp_file(self): - """Tests that _create_temp_file creates a file with correct content and deletes it.""" - content = b"test cert data" - - # Test file creation and content - with mtls._create_temp_file(content) as file_path: - assert os.path.exists(file_path) - # Verify file is not readable by others (mkstemp default) - if os.name == "posix": - assert (os.stat(file_path).st_mode & 0o777) == 0o600 - - with open(file_path, "rb") as f: - assert f.read() == content - - # Test file deletion after context exit - assert not os.path.exists(file_path) - @pytest.mark.asyncio async def test_make_client_cert_ssl_context_success(self): """Tests successful creation of an SSLContext with client certificates.""" diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index d13f0fac2f20..76da57cf66a0 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -20,7 +20,6 @@ from unittest import mock import freezegun -import OpenSSL import pytest # type: ignore import requests import requests.adapters @@ -192,18 +191,11 @@ def test_success(self, mock_proxy_manager_for, mock_init_poolmanager): mock_proxy_manager_for.assert_called_with(ssl_context=adapter._ctx_proxymanager) def test_invalid_cert_or_key(self): - with pytest.raises(OpenSSL.crypto.Error): + with pytest.raises(exceptions.MutualTLSChannelError): google.auth.transport.requests._MutualTlsAdapter( b"invalid cert", b"invalid key" ) - @mock.patch.dict("sys.modules", {"OpenSSL.crypto": None}) - def test_import_error(self): - with pytest.raises(ImportError): - google.auth.transport.requests._MutualTlsAdapter( - pytest.public_cert_bytes, pytest.private_key_bytes - ) - def make_response(status=http_client.OK, data=None): response = requests.Response() @@ -496,9 +488,29 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): auth_session.configure_mtls_channel() assert auth_session._is_mtls is False - mock_get_client_cert_and_key.return_value = (False, None, None) - with mock.patch.dict("sys.modules"): - sys.modules["OpenSSL"] = None + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + @mock.patch("google.auth.transport.requests.create_urllib3_context", autospec=True) + def test_configure_mtls_channel_cert_loading_exceptions( + self, mock_create_urllib3_context, mock_get_client_cert_and_key + ): + import ssl + + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + for exception_type in [ValueError("error"), ssl.SSLError("error")]: + mock_ctx = mock.Mock() + mock_ctx.load_cert_chain.side_effect = exception_type + mock_create_urllib3_context.return_value = mock_ctx + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) with pytest.raises(exceptions.MutualTLSChannelError): with mock.patch.dict( os.environ, @@ -507,6 +519,8 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): auth_session.configure_mtls_channel() assert auth_session._is_mtls is False + assert not auth_session.is_mtls + @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 04ace52a2350..5c06bebd566e 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -17,7 +17,6 @@ import sys from unittest import mock -import OpenSSL import pytest # type: ignore import urllib3 # type: ignore @@ -103,18 +102,11 @@ def test_success(self): assert isinstance(http, urllib3.PoolManager) def test_crypto_error(self): - with pytest.raises(OpenSSL.crypto.Error): + with pytest.raises(exceptions.MutualTLSChannelError): google.auth.transport.urllib3._make_mutual_tls_http( b"invalid cert", b"invalid key" ) - @mock.patch.dict("sys.modules", {"OpenSSL.crypto": None}) - def test_import_error(self): - with pytest.raises(ImportError): - google.auth.transport.urllib3._make_mutual_tls_http( - pytest.public_cert_bytes, pytest.private_key_bytes - ) - class TestAuthorizedHttp(object): TEST_URL = "http://example.com" @@ -292,9 +284,33 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): authed_http.configure_mtls_channel() assert authed_http._is_mtls is False - mock_get_client_cert_and_key.return_value = (False, None, None) - with mock.patch.dict("sys.modules"): - sys.modules["OpenSSL"] = None + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + @mock.patch( + "google.auth.transport.urllib3.urllib3.util.ssl_.create_urllib3_context", + autospec=True, + ) + def test_configure_mtls_channel_cert_loading_exceptions( + self, mock_create_urllib3_context, mock_get_client_cert_and_key + ): + import ssl + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + mock_get_client_cert_and_key.return_value = ( + True, + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + for exception_type in [ValueError("error"), ssl.SSLError("error")]: + mock_ctx = mock.Mock() + mock_ctx.load_cert_chain.side_effect = exception_type + mock_create_urllib3_context.return_value = mock_ctx + with pytest.raises(exceptions.MutualTLSChannelError): with mock.patch.dict( os.environ, @@ -303,6 +319,8 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): authed_http.configure_mtls_channel() assert authed_http._is_mtls is False + assert not authed_http._is_mtls + @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) From 7fd35594e04f2fe9c8c46058c9a988ae661a391f Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Wed, 10 Jun 2026 04:04:32 +0000 Subject: [PATCH 02/25] fix: resolve mypy and lint issues --- .../google/auth/aio/transport/mtls.py | 5 +---- .../google/auth/transport/_mtls_helper.py | 18 ++++++++++++------ .../tests/transport/test__mtls_helper.py | 3 +-- .../tests/transport/test_requests.py | 1 - .../tests/transport/test_urllib3.py | 1 - 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/mtls.py b/packages/google-auth/google/auth/aio/transport/mtls.py index aee96ccefacb..be48b1dbbd4e 100644 --- a/packages/google-auth/google/auth/aio/transport/mtls.py +++ b/packages/google-auth/google/auth/aio/transport/mtls.py @@ -17,16 +17,13 @@ """ import asyncio -import contextlib import logging -import os import ssl -import tempfile from typing import Optional from google.auth import exceptions -import google.auth.transport.mtls from google.auth.transport._mtls_helper import secure_cert_key_paths +import google.auth.transport.mtls _LOGGER = logging.getLogger(__name__) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 1bb2219580f7..f1f66c60f176 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -20,7 +20,7 @@ from os import environ, getenv, path import re import subprocess -from typing import Generator, Optional, Tuple, Union +from typing import cast, Generator, Optional, Tuple, Union from google.auth import _agent_identity_utils from google.auth import environment_vars @@ -124,7 +124,9 @@ def secure_cert_key_paths( if (cert_path is None or os.path.exists(cert_path)) and ( key_path is None or os.path.exists(key_path) ): - yield cert_path or cert, key_path or key, passphrase + yield cast(str, cert_path or cert), cast( + str, key_path or key + ), passphrase return finally: import sys @@ -143,7 +145,7 @@ def secure_cert_key_paths( key_path, new_passphrase, ): - yield cert_path or cert, key_path or key, new_passphrase + yield cast(str, cert_path or cert), cast(str, key_path or key), new_passphrase def _encrypt_key_if_plaintext( @@ -205,7 +207,9 @@ def _secure_wipe_and_remove(file_path: str): @contextlib.contextmanager -def _memfd_cert_key_paths(cert_bytes: Optional[bytes], key_bytes: Optional[bytes]): +def _memfd_cert_key_paths( + cert_bytes: Optional[bytes], key_bytes: Optional[bytes] +) -> Generator[Tuple[Optional[str], Optional[str]], None, None]: """Creates secure, in-memory virtual files on Linux using memfd_create. Yields: @@ -243,8 +247,10 @@ def _memfd_cert_key_paths(cert_bytes: Optional[bytes], key_bytes: Optional[bytes @contextlib.contextmanager def _tempfile_cert_key_paths( - cert_bytes: Optional[bytes], key_bytes: Optional[bytes], passphrase: Optional[bytes] -): + cert_bytes: Optional[bytes], + key_bytes: Optional[bytes], + passphrase: Optional[bytes], +) -> Generator[Tuple[Optional[str], Optional[str], Optional[bytes]], None, None]: """Creates secure temporary file paths on disk, encrypting private keys. Yields: diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index a87d52c3bfac..87beb8fd78af 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -16,9 +16,8 @@ import re from unittest import mock -from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives import hashes import pytest # type: ignore from google.auth import environment_vars, exceptions diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index 76da57cf66a0..673119493a0b 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -16,7 +16,6 @@ import functools import http.client as http_client import os -import sys from unittest import mock import freezegun diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 5c06bebd566e..8ebcacf6d02c 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -14,7 +14,6 @@ import http.client as http_client import os -import sys from unittest import mock import pytest # type: ignore From 4a4f582ce24519e493591886651217c020c0bd70 Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Wed, 10 Jun 2026 04:32:31 +0000 Subject: [PATCH 03/25] fix: suppress interactive OpenSSL stdin passphrase prompts during mTLS cert loading fallbacks --- packages/google-auth/google/auth/aio/transport/mtls.py | 4 +++- packages/google-auth/google/auth/compute_engine/_mtls.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/mtls.py b/packages/google-auth/google/auth/aio/transport/mtls.py index be48b1dbbd4e..bbbd39ff1950 100644 --- a/packages/google-auth/google/auth/aio/transport/mtls.py +++ b/packages/google-auth/google/auth/aio/transport/mtls.py @@ -54,7 +54,9 @@ def make_client_cert_ssl_context( try: context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) context.load_cert_chain( - certfile=cert_path, keyfile=key_path, password=passphrase_val + certfile=cert_path, + keyfile=key_path, + password=passphrase_val or "", ) return context except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: diff --git a/packages/google-auth/google/auth/compute_engine/_mtls.py b/packages/google-auth/google/auth/compute_engine/_mtls.py index a427e66a89b3..d475c1e59a9e 100644 --- a/packages/google-auth/google/auth/compute_engine/_mtls.py +++ b/packages/google-auth/google/auth/compute_engine/_mtls.py @@ -120,7 +120,7 @@ def __init__( self.ssl_context = ssl.create_default_context() self.ssl_context.load_verify_locations(cafile=mds_mtls_config.ca_cert_path) self.ssl_context.load_cert_chain( - certfile=mds_mtls_config.client_combined_cert_path + certfile=mds_mtls_config.client_combined_cert_path, password="" ) super(MdsMtlsAdapter, self).__init__(*args, **kwargs) From 19b29abc9d6321b158b93dedc48f1fb912aafa9d Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Wed, 10 Jun 2026 05:42:31 +0000 Subject: [PATCH 04/25] add unit tests to mtls_helper --- .../google/auth/transport/_mtls_helper.py | 4 +- .../google/auth/transport/requests.py | 8 +- .../google/auth/transport/urllib3.py | 4 +- .../tests/transport/test__mtls_helper.py | 278 ++++++++++++++++++ 4 files changed, 289 insertions(+), 5 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index f1f66c60f176..fc0ee6af47cf 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -225,15 +225,15 @@ def _memfd_cert_key_paths( if cert_bytes is not None: # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. fd_cert = os.memfd_create("mtls_cert", os.MFD_CLOEXEC) + cleanup_fds.append(fd_cert) os.write(fd_cert, cert_bytes) cert_path = f"/proc/self/fd/{fd_cert}" - cleanup_fds.append(fd_cert) if key_bytes is not None: fd_key = os.memfd_create("mtls_key", os.MFD_CLOEXEC) + cleanup_fds.append(fd_key) os.write(fd_key, key_bytes) key_path = f"/proc/self/fd/{fd_key}" - cleanup_fds.append(fd_key) yield cert_path, key_path finally: diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index 32b3cf738c25..89945d536263 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -224,10 +224,14 @@ def __init__(self, cert, key): ): try: ctx_poolmanager.load_cert_chain( - certfile=cert_path, keyfile=key_path, password=passphrase + certfile=cert_path, + keyfile=key_path, + password=passphrase or "", ) ctx_proxymanager.load_cert_chain( - certfile=cert_path, keyfile=key_path, password=passphrase + certfile=cert_path, + keyfile=key_path, + password=passphrase or "", ) except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: raise exceptions.MutualTLSChannelError( diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index d4889e511871..022c5708ba04 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -189,7 +189,9 @@ def _make_mutual_tls_http(cert, key): ): try: ctx.load_cert_chain( - certfile=cert_path, keyfile=key_path, password=passphrase + certfile=cert_path, + keyfile=key_path, + password=passphrase or "", ) except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: raise exceptions.MutualTLSChannelError( diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 87beb8fd78af..332d15f5e0ee 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -14,6 +14,8 @@ import os import re +import sys +import tempfile from unittest import mock from cryptography.hazmat.primitives import hashes, serialization @@ -1022,3 +1024,279 @@ def test_call_client_cert_callback(self, mock_get_client_ssl_credentials): mock_get_client_ssl_credentials.assert_called_once_with( generate_encrypted_key=True ) + + +class TestSecureCertKeyPaths(object): + def test_tier1_pass_through(self): + with _mtls_helper.secure_cert_key_paths( + "/path/to/cert", "/path/to/key", b"passphrase" + ) as (cert_path, key_path, passphrase): + assert cert_path == "/path/to/cert" + assert key_path == "/path/to/key" + assert passphrase == b"passphrase" + + @mock.patch.object(sys, "platform", "linux") + @mock.patch.object(os, "memfd_create", create=True) + @mock.patch.object(_mtls_helper, "_memfd_cert_key_paths", autospec=True) + def test_tier2_memfd_success(self, mock_memfd_cm, mock_memfd_create): + mock_memfd_ctx = mock.MagicMock() + mock_memfd_ctx.__enter__.return_value = ( + "/proc/self/fd/3", + "/proc/self/fd/4", + ) + mock_memfd_cm.return_value = mock_memfd_ctx + + with mock.patch.object(os.path, "exists", return_value=True): + with _mtls_helper.secure_cert_key_paths( + pytest.public_cert_bytes, + pytest.private_key_bytes, + b"passphrase", + ) as (cert_path, key_path, passphrase): + assert cert_path == "/proc/self/fd/3" + assert key_path == "/proc/self/fd/4" + assert passphrase == b"passphrase" + assert mock_memfd_ctx.__exit__.called + + @mock.patch.object(sys, "platform", "linux") + @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_restricted_filesystem( + self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create + ): + mock_memfd_ctx = mock.MagicMock() + mock_memfd_ctx.__enter__.return_value = ( + "/proc/self/fd/3", + "/proc/self/fd/4", + ) + mock_memfd_cm.return_value = mock_memfd_ctx + + mock_tempfile_ctx = mock.MagicMock() + mock_tempfile_ctx.__enter__.return_value = ( + "/tmp/cert", + "/tmp/key", + b"new_pass", + ) + mock_tempfile_cm.return_value = mock_tempfile_ctx + + with mock.patch.object(os.path, "exists", return_value=False): + with _mtls_helper.secure_cert_key_paths( + pytest.public_cert_bytes, pytest.private_key_bytes, b"passphrase" + ) as (cert_path, key_path, passphrase): + assert cert_path == "/tmp/cert" + assert key_path == "/tmp/key" + assert passphrase == b"new_pass" + mock_memfd_ctx.__exit__.assert_called_once_with(None, None, None) + + @mock.patch.object(sys, "platform", "linux") + @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( + self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create + ): + mock_memfd_ctx = mock.MagicMock() + mock_memfd_ctx.__enter__.side_effect = OSError("memfd failed") + mock_memfd_cm.return_value = mock_memfd_ctx + + mock_tempfile_ctx = mock.MagicMock() + mock_tempfile_ctx.__enter__.return_value = ( + "/tmp/cert", + "/tmp/key", + b"new_pass", + ) + mock_tempfile_cm.return_value = mock_tempfile_ctx + + with _mtls_helper.secure_cert_key_paths( + pytest.public_cert_bytes, pytest.private_key_bytes, b"passphrase" + ) as (cert_path, key_path, passphrase): + assert cert_path == "/tmp/cert" + assert key_path == "/tmp/key" + assert passphrase == b"new_pass" + + @mock.patch.object(sys, "platform", "darwin") + @mock.patch.object(_mtls_helper, "_tempfile_cert_key_paths", autospec=True) + def test_tier3_tempfile_success_non_linux(self, mock_tempfile_cm): + mock_tempfile_ctx = mock.MagicMock() + mock_tempfile_ctx.__enter__.return_value = ( + "/tmp/cert", + "/tmp/key", + b"new_pass", + ) + mock_tempfile_cm.return_value = mock_tempfile_ctx + + with _mtls_helper.secure_cert_key_paths( + pytest.public_cert_bytes, pytest.private_key_bytes, b"passphrase" + ) as (cert_path, key_path, passphrase): + assert cert_path == "/tmp/cert" + assert key_path == "/tmp/key" + assert passphrase == b"new_pass" + + @mock.patch.object(sys, "platform", "darwin") + @mock.patch.object(_mtls_helper, "_tempfile_cert_key_paths", autospec=True) + def test_hybrid_inputs(self, mock_tempfile_cm): + mock_tempfile_ctx = mock.MagicMock() + mock_tempfile_ctx.__enter__.return_value = ( + None, + "/tmp/key", + b"new_pass", + ) + mock_tempfile_cm.return_value = mock_tempfile_ctx + + with _mtls_helper.secure_cert_key_paths( + "/pass/through/cert.pem", pytest.private_key_bytes, b"passphrase" + ) as (cert_path, key_path, passphrase): + assert cert_path == "/pass/through/cert.pem" + assert key_path == "/tmp/key" + assert passphrase == b"new_pass" + + +class TestMemfdCertKeyPaths(object): + @mock.patch.object(os, "memfd_create", create=True) + @mock.patch.object(os, "write") + @mock.patch.object(os, "close") + def test_success_both_bytes(self, mock_close, mock_write, mock_memfd_create): + mock_memfd_create.side_effect = [10, 11] + with _mtls_helper._memfd_cert_key_paths(b"cert", b"key") as ( + cert_path, + key_path, + ): + assert cert_path == "/proc/self/fd/10" + assert key_path == "/proc/self/fd/11" + mock_write.assert_has_calls([mock.call(10, b"cert"), mock.call(11, b"key")]) + assert mock_close.call_count == 2 + + @mock.patch.object(os, "memfd_create", create=True) + @mock.patch.object(os, "write") + @mock.patch.object(os, "close") + def test_close_ignores_oserror(self, mock_close, mock_write, mock_memfd_create): + mock_memfd_create.return_value = 12 + mock_close.side_effect = OSError("close error") + with _mtls_helper._memfd_cert_key_paths(b"cert", None) as (cert_path, key_path): + assert cert_path == "/proc/self/fd/12" + assert key_path is None + mock_close.assert_called_once_with(12) + + @mock.patch.object(os, "memfd_create", create=True) + @mock.patch.object(os, "write") + @mock.patch.object(os, "close") + def test_write_oserror_prevents_fd_leak( + self, mock_close, mock_write, mock_memfd_create + ): + mock_memfd_create.return_value = 15 + mock_write.side_effect = OSError("write fault") + with pytest.raises(OSError): + with _mtls_helper._memfd_cert_key_paths(b"cert", None): + pass + mock_close.assert_called_once_with(15) + + +class TestTempfileCertKeyPaths(object): + @mock.patch.object(os.path, "isdir", return_value=True) + @mock.patch.object(tempfile, "mkstemp") + @mock.patch.object(os, "fdopen") + @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) + @mock.patch.object(_mtls_helper, "_secure_wipe_and_remove", autospec=True) + def test_success_shm( + self, + mock_wipe, + mock_encrypt, + mock_fdopen, + mock_mkstemp, + mock_isdir, + ): + mock_mkstemp.side_effect = [(1, "/shm/cert"), (2, "/shm/key")] + mock_encrypt.return_value = (b"encrypted_key", b"new_pass") + mock_file = mock.MagicMock() + mock_file.fileno.return_value = 1 + mock_fdopen.return_value.__enter__.return_value = mock_file + + with mock.patch.object(os, "remove") as mock_remove, mock.patch.object( + os.path, "exists", return_value=True + ): + with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass") as ( + cert_path, + key_path, + passphrase, + ): + assert cert_path == "/shm/cert" + assert key_path == "/shm/key" + assert passphrase == b"new_pass" + mock_remove.assert_called_once_with("/shm/cert") + + mock_mkstemp.assert_has_calls( + [mock.call(dir="/dev/shm"), mock.call(dir="/dev/shm")] + ) + mock_wipe.assert_called_once_with("/shm/key") + + @mock.patch.object(os.path, "isdir", return_value=True) + @mock.patch.object(tempfile, "mkstemp") + @mock.patch.object(os, "fdopen") + @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) + @mock.patch.object(_mtls_helper, "_secure_wipe_and_remove", autospec=True) + def test_permission_error_loop_resilience( + self, + mock_wipe, + mock_encrypt, + mock_fdopen, + mock_mkstemp, + mock_isdir, + ): + mock_mkstemp.side_effect = [(1, "/shm/cert"), (2, "/shm/key")] + mock_encrypt.return_value = (b"encrypted_key", b"new_pass") + mock_file = mock.MagicMock() + mock_file.fileno.return_value = 1 + mock_fdopen.return_value.__enter__.return_value = mock_file + + mock_wipe.side_effect = PermissionError("lock error") + + with mock.patch.object(os, "remove") as mock_remove, mock.patch.object( + os.path, "exists", return_value=True + ): + with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass"): + pass + mock_remove.assert_called_once_with("/shm/cert") + + +class TestEncryptKeyIfPlaintext(object): + def test_encrypts_plaintext_key(self): + encrypted_bytes, passphrase = _mtls_helper._encrypt_key_if_plaintext( + pytest.private_key_bytes, b"my_passphrase" + ) + assert passphrase == b"my_passphrase" + assert encrypted_bytes != pytest.private_key_bytes + assert b"ENCRYPTED PRIVATE KEY" in encrypted_bytes + + decrypted = serialization.load_pem_private_key( + encrypted_bytes, password=b"my_passphrase" + ) + assert decrypted + + @mock.patch("secrets.token_hex", return_value="0123456789abcdef0123456789abcdef") + def test_default_passphrase_generation(self, mock_secrets): + encrypted_bytes, passphrase = _mtls_helper._encrypt_key_if_plaintext( + pytest.private_key_bytes, None + ) + assert passphrase == b"0123456789abcdef0123456789abcdef" + assert b"ENCRYPTED PRIVATE KEY" in encrypted_bytes + + +class TestSecureWipeAndRemove(object): + @mock.patch.object(os.path, "exists", return_value=True) + @mock.patch.object(os.path, "getsize", return_value=10) + @mock.patch("builtins.open", autospec=True) + @mock.patch.object(os, "fsync") + @mock.patch.object(os, "remove") + def test_success( + self, mock_remove, mock_fsync, mock_open, mock_getsize, mock_exists + ): + mock_fh = mock.MagicMock() + mock_fh.fileno.return_value = 1 + mock_open.return_value.__enter__.return_value = mock_fh + + _mtls_helper._secure_wipe_and_remove("/path/to/secret") + + mock_open.assert_called_once_with("/path/to/secret", "r+b") + mock_fh.write.assert_called_once_with(b"\0" * 10) + mock_fsync.assert_called_once() + mock_remove.assert_called_once_with("/path/to/secret") From 648a9b01426d45ed8100e901cb8bc7b0a5b1487c Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:03:08 -0700 Subject: [PATCH 05/25] refactor(auth): use os.fdopen for writing to memfd in mtls helper and update tests accordingly --- .../google/auth/transport/_mtls_helper.py | 6 ++- .../tests/transport/test__mtls_helper.py | 38 +++++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index fc0ee6af47cf..7650b0eb8828 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -226,13 +226,15 @@ def _memfd_cert_key_paths( # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. fd_cert = os.memfd_create("mtls_cert", os.MFD_CLOEXEC) cleanup_fds.append(fd_cert) - os.write(fd_cert, cert_bytes) + with os.fdopen(fd_cert, "wb", closefd=False) as f: + f.write(cert_bytes) cert_path = f"/proc/self/fd/{fd_cert}" if key_bytes is not None: fd_key = os.memfd_create("mtls_key", os.MFD_CLOEXEC) cleanup_fds.append(fd_key) - os.write(fd_key, key_bytes) + with os.fdopen(fd_key, "wb", closefd=False) as f: + f.write(key_bytes) key_path = f"/proc/self/fd/{fd_key}" yield cert_path, key_path diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 332d15f5e0ee..12948e6092f0 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -13,6 +13,8 @@ # limitations under the License. import os +if not hasattr(os, "MFD_CLOEXEC"): + os.MFD_CLOEXEC = 1 import re import sys import tempfile @@ -1153,41 +1155,61 @@ def test_hybrid_inputs(self, mock_tempfile_cm): class TestMemfdCertKeyPaths(object): @mock.patch.object(os, "memfd_create", create=True) - @mock.patch.object(os, "write") + @mock.patch.object(os, "fdopen") @mock.patch.object(os, "close") - def test_success_both_bytes(self, mock_close, mock_write, mock_memfd_create): + def test_success_both_bytes(self, mock_close, mock_fdopen, mock_memfd_create): mock_memfd_create.side_effect = [10, 11] + mock_file_cert = mock.MagicMock() + mock_file_cert.__enter__.return_value = mock_file_cert + mock_file_key = mock.MagicMock() + mock_file_key.__enter__.return_value = mock_file_key + mock_fdopen.side_effect = [mock_file_cert, mock_file_key] with _mtls_helper._memfd_cert_key_paths(b"cert", b"key") as ( cert_path, key_path, ): assert cert_path == "/proc/self/fd/10" assert key_path == "/proc/self/fd/11" - mock_write.assert_has_calls([mock.call(10, b"cert"), mock.call(11, b"key")]) + mock_fdopen.assert_has_calls([ + mock.call(10, "wb", closefd=False), + mock.call(11, "wb", closefd=False) + ]) + mock_file_cert.write.assert_called_once_with(b"cert") + mock_file_key.write.assert_called_once_with(b"key") assert mock_close.call_count == 2 @mock.patch.object(os, "memfd_create", create=True) - @mock.patch.object(os, "write") + @mock.patch.object(os, "fdopen") @mock.patch.object(os, "close") - def test_close_ignores_oserror(self, mock_close, mock_write, mock_memfd_create): + def test_close_ignores_oserror(self, mock_close, mock_fdopen, mock_memfd_create): mock_memfd_create.return_value = 12 mock_close.side_effect = OSError("close error") + mock_file = mock.MagicMock() + mock_file.__enter__.return_value = mock_file + mock_fdopen.return_value = mock_file with _mtls_helper._memfd_cert_key_paths(b"cert", None) as (cert_path, key_path): assert cert_path == "/proc/self/fd/12" assert key_path is None + mock_fdopen.assert_called_once_with(12, "wb", closefd=False) + mock_file.write.assert_called_once_with(b"cert") mock_close.assert_called_once_with(12) @mock.patch.object(os, "memfd_create", create=True) - @mock.patch.object(os, "write") + @mock.patch.object(os, "fdopen") @mock.patch.object(os, "close") def test_write_oserror_prevents_fd_leak( - self, mock_close, mock_write, mock_memfd_create + self, mock_close, mock_fdopen, mock_memfd_create ): mock_memfd_create.return_value = 15 - mock_write.side_effect = OSError("write fault") + mock_file = mock.MagicMock() + mock_file.__enter__.return_value = mock_file + mock_file.write.side_effect = OSError("write fault") + mock_fdopen.return_value = mock_file with pytest.raises(OSError): with _mtls_helper._memfd_cert_key_paths(b"cert", None): pass + mock_fdopen.assert_called_once_with(15, "wb", closefd=False) + mock_file.write.assert_called_once_with(b"cert") mock_close.assert_called_once_with(15) From 553b05c0c79fcf3f4430bf6a903c972950d20ed1 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:04:46 -0700 Subject: [PATCH 06/25] test(auth): fix failing test by updating mock_mds_mtls_config assertion to include empty password for client_combined_cert_path --- packages/google-auth/tests/compute_engine/test__mtls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-auth/tests/compute_engine/test__mtls.py b/packages/google-auth/tests/compute_engine/test__mtls.py index 2effa29bbdc2..eb7b919fd374 100644 --- a/packages/google-auth/tests/compute_engine/test__mtls.py +++ b/packages/google-auth/tests/compute_engine/test__mtls.py @@ -123,7 +123,7 @@ def test_mds_mtls_adapter_init(mock_ssl_context, mock_mds_mtls_config): cafile=mock_mds_mtls_config.ca_cert_path ) adapter.ssl_context.load_cert_chain.assert_called_once_with( - certfile=mock_mds_mtls_config.client_combined_cert_path + certfile=mock_mds_mtls_config.client_combined_cert_path, password="" ) From 91d0fe84872a27ceb10839524702b82970cdfd62 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:55:35 -0700 Subject: [PATCH 07/25] fix nox failures --- .../google-auth/google/auth/transport/_mtls_helper.py | 4 ++-- .../google-auth/tests/transport/test__mtls_helper.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 7650b0eb8828..54cafe13ed68 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -224,14 +224,14 @@ def _memfd_cert_key_paths( try: if cert_bytes is not None: # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. - fd_cert = os.memfd_create("mtls_cert", os.MFD_CLOEXEC) + fd_cert = os.memfd_create("mtls_cert", os.MFD_CLOEXEC) # type: ignore[attr-defined] cleanup_fds.append(fd_cert) with os.fdopen(fd_cert, "wb", closefd=False) as f: f.write(cert_bytes) cert_path = f"/proc/self/fd/{fd_cert}" if key_bytes is not None: - fd_key = os.memfd_create("mtls_key", os.MFD_CLOEXEC) + fd_key = os.memfd_create("mtls_key", os.MFD_CLOEXEC) # type: ignore[attr-defined] cleanup_fds.append(fd_key) with os.fdopen(fd_key, "wb", closefd=False) as f: f.write(key_bytes) diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 12948e6092f0..e670090d781b 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -13,8 +13,9 @@ # limitations under the License. import os + if not hasattr(os, "MFD_CLOEXEC"): - os.MFD_CLOEXEC = 1 + setattr(os, "MFD_CLOEXEC", 1) import re import sys import tempfile @@ -1170,10 +1171,9 @@ def test_success_both_bytes(self, mock_close, mock_fdopen, mock_memfd_create): ): assert cert_path == "/proc/self/fd/10" assert key_path == "/proc/self/fd/11" - mock_fdopen.assert_has_calls([ - mock.call(10, "wb", closefd=False), - mock.call(11, "wb", closefd=False) - ]) + mock_fdopen.assert_has_calls( + [mock.call(10, "wb", closefd=False), mock.call(11, "wb", closefd=False)] + ) mock_file_cert.write.assert_called_once_with(b"cert") mock_file_key.write.assert_called_once_with(b"key") assert mock_close.call_count == 2 From 33c8377d7653da0074ecea214f66ffc8b93b569e Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:03:50 -0700 Subject: [PATCH 08/25] test: add edge case and error handling tests for _mtls_helper functions --- .../tests/transport/test__mtls_helper.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index e670090d781b..0041c36f3c65 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -1302,6 +1302,21 @@ def test_default_passphrase_generation(self, mock_secrets): assert passphrase == b"0123456789abcdef0123456789abcdef" assert b"ENCRYPTED PRIVATE KEY" in encrypted_bytes + def test_returns_encrypted_key_asis(self): + encrypted_bytes, passphrase = _mtls_helper._encrypt_key_if_plaintext( + ENCRYPTED_EC_PRIVATE_KEY, b"passphrase" + ) + assert encrypted_bytes == ENCRYPTED_EC_PRIVATE_KEY + assert passphrase == b"passphrase" + + def test_returns_invalid_key_asis(self): + invalid_bytes = b"not a valid key" + encrypted_bytes, passphrase = _mtls_helper._encrypt_key_if_plaintext( + invalid_bytes, b"passphrase" + ) + assert encrypted_bytes == invalid_bytes + assert passphrase == b"passphrase" + class TestSecureWipeAndRemove(object): @mock.patch.object(os.path, "exists", return_value=True) @@ -1322,3 +1337,49 @@ def test_success( mock_fh.write.assert_called_once_with(b"\0" * 10) mock_fsync.assert_called_once() mock_remove.assert_called_once_with("/path/to/secret") + + @mock.patch.object(os.path, "exists", return_value=False) + @mock.patch.object(os, "remove") + def test_file_not_found(self, mock_remove, mock_exists): + _mtls_helper._secure_wipe_and_remove("/path/to/nonexistent") + + mock_exists.assert_called_once_with("/path/to/nonexistent") + mock_remove.assert_not_called() + + @mock.patch.object(os.path, "exists", return_value=True) + @mock.patch.object(os.path, "getsize", return_value=10) + @mock.patch("builtins.open", autospec=True) + @mock.patch.object(os, "fsync") + @mock.patch.object(os, "remove") + def test_write_oserror_ignored( + self, mock_remove, mock_fsync, mock_open, mock_getsize, mock_exists + ): + mock_fh = mock.MagicMock() + mock_fh.fileno.return_value = 1 + mock_fh.write.side_effect = OSError("write fault") + mock_open.return_value.__enter__.return_value = mock_fh + + _mtls_helper._secure_wipe_and_remove("/path/to/secret") + + mock_open.assert_called_once_with("/path/to/secret", "r+b") + mock_fsync.assert_not_called() + mock_remove.assert_called_once_with("/path/to/secret") + + @mock.patch.object(os.path, "exists", return_value=True) + @mock.patch.object(os.path, "getsize", return_value=10) + @mock.patch("builtins.open", autospec=True) + @mock.patch.object(os, "fsync") + @mock.patch.object(os, "remove") + def test_remove_oserror_ignored( + self, mock_remove, mock_fsync, mock_open, mock_getsize, mock_exists + ): + mock_fh = mock.MagicMock() + mock_fh.fileno.return_value = 1 + mock_open.return_value.__enter__.return_value = mock_fh + mock_remove.side_effect = OSError("remove fault") + + _mtls_helper._secure_wipe_and_remove("/path/to/secret") + + mock_open.assert_called_once_with("/path/to/secret", "r+b") + mock_fsync.assert_called_once() + mock_remove.assert_called_once_with("/path/to/secret") From 03ab6ce5df29b4c4458018b1a0bcd876c32f9331 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:14:56 -0700 Subject: [PATCH 09/25] fix lint error, again! --- packages/google-auth/tests/transport/test__mtls_helper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 0041c36f3c65..6cf61ee9c3e2 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -13,9 +13,6 @@ # limitations under the License. import os - -if not hasattr(os, "MFD_CLOEXEC"): - setattr(os, "MFD_CLOEXEC", 1) import re import sys import tempfile @@ -28,6 +25,9 @@ from google.auth import environment_vars, exceptions from google.auth.transport import _mtls_helper +if not hasattr(os, "MFD_CLOEXEC"): + setattr(os, "MFD_CLOEXEC", 1) + CERT_MOCK_VAL = b"cert" KEY_MOCK_VAL = b"key" CONTEXT_AWARE_METADATA = {"cert_provider_command": ["some command"]} From 73e6e91e5582581c33542381708ca55aec5c515d Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:20:57 -0700 Subject: [PATCH 10/25] refactor(auth): address PR comments on imports, exit call, and safety checks --- .../google-auth/google/auth/identity_pool.py | 5 +++- .../google/auth/transport/_mtls_helper.py | 24 +++++++------------ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 455065590a24..411cf7128cdf 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -154,7 +154,10 @@ def __init__(self, trust_chain_path, leaf_cert_callback): def get_subject_token(self, context, request): from cryptography import x509 - leaf_cert = x509.load_pem_x509_certificate(self._leaf_cert_callback()) + leaf_cert_data = self._leaf_cert_callback() + if isinstance(leaf_cert_data, str): + leaf_cert_data = leaf_cert_data.encode("utf-8") + leaf_cert = x509.load_pem_x509_certificate(leaf_cert_data) trust_chain = self._read_trust_chain() cert_chain = [] diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 54cafe13ed68..bc6cfe267b05 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -17,9 +17,12 @@ import contextlib import json import logging +import os from os import environ, getenv, path import re import subprocess +import sys +import tempfile from typing import cast, Generator, Optional, Tuple, Union from google.auth import _agent_identity_utils @@ -97,9 +100,6 @@ def secure_cert_key_paths( the passphrase needed to load the key (either the user's original, or the newly generated one if Tier 3 had to encrypt the key). """ - import os - import sys - # Tier 1: Pass-through (No-op). If the caller already provided file paths, # we yield them directly to avoid any unnecessary file creation. if isinstance(cert, str) and isinstance(key, str): @@ -129,12 +129,7 @@ def secure_cert_key_paths( ), passphrase return finally: - import sys - - exc_info = sys.exc_info() - cm.__exit__( - *(exc_info if exc_info[0] is not None else (None, None, None)) - ) + cm.__exit__(*sys.exc_info()) # If verification failed, fall through to Tier 3. # Tier 3: Fallback Encrypted Temp Files. If in-memory files are not supported @@ -216,8 +211,6 @@ def _memfd_cert_key_paths( Tuple[Optional[str], Optional[str]]: In-memory file paths pointing to the active descriptors (e.g., '/proc/self/fd/3'). """ - import os - cleanup_fds = [] cert_path, key_path = None, None @@ -259,11 +252,12 @@ def _tempfile_cert_key_paths( Tuple[Optional[str], Optional[str], Optional[bytes]]: The temporary file paths and the passphrase needed to load the key. """ - import os - import tempfile - # Prioritize RAM-backed /dev/shm to avoid writing secrets to physical storage. - tmp_dir = "/dev/shm" if os.path.isdir("/dev/shm") else None + tmp_dir = ( + "/dev/shm" + if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) + else None + ) cert_path, key_path = None, None cleanup_files = [] new_passphrase = passphrase From 2d1bb5c1b10a20e4b9fe931de7ef4cfb6536f8c7 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:22:27 -0700 Subject: [PATCH 11/25] docs/refactor(auth): improve secure_cert_key_paths docstrings and refactor file writing into loops --- .../google/auth/transport/_mtls_helper.py | 93 ++++++++++--------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index bc6cfe267b05..83c015539c24 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -78,27 +78,32 @@ @contextlib.contextmanager def secure_cert_key_paths( - cert: Union[str, bytes], - key: Union[str, bytes], + cert: Union[bytes, str, None], + key: Union[bytes, str, None], passphrase: Optional[bytes] = None, ) -> Generator[Tuple[str, str, Optional[bytes]], None, None]: """Provides secure file paths for certificate and key. - Standard TLS libraries (like Python's standard library `ssl`) require file paths to - load credentials. To minimize exposure of raw private key bytes on physical storage, - this context manager implements a three-tier fallback strategy: yielding pass-through - paths (Tier 1), using RAM-backed virtual files on Linux (Tier 2), or falling back - to encrypted temporary files on disk (Tier 3). + This function is implemented as a context manager generator to ensure that + any temporary resources (such as in-memory virtual files or encrypted physical + temp files) are automatically cleaned up and securely wiped when the context exits. + + It supports mixed inputs (e.g. passing one as a string path and the other as bytes). + If a parameter is already a string path or None, it is passed through as-is, and + only raw bytes are written to temporary storage. Args: - cert (Union[str, bytes]): Certificate path or raw PEM content bytes. - key (Union[str, bytes]): Private key path or raw PEM content bytes. + cert (Union[str, bytes, None]): Certificate path, raw PEM content bytes, or None. + key (Union[str, bytes, None]): Private key path, raw PEM content bytes, or None. passphrase (Optional[bytes]): Optional passphrase for the private key. Yields: Tuple[str, str, Optional[bytes]]: The certificate path, key path, and the passphrase needed to load the key (either the user's original, or the newly generated one if Tier 3 had to encrypt the key). + + Raises: + OSError: If temporary file creation or writing fails during the Tier 3 fallback. """ # Tier 1: Pass-through (No-op). If the caller already provided file paths, # we yield them directly to avoid any unnecessary file creation. @@ -106,6 +111,8 @@ def secure_cert_key_paths( yield cert, key, passphrase return + # If a value is a string path, it is passed through. If bytes, we will write + # it to temporary storage. None values are also passed through as-is. cert_bytes = cert if isinstance(cert, bytes) else None key_bytes = key if isinstance(key, bytes) else None @@ -212,24 +219,22 @@ def _memfd_cert_key_paths( the active descriptors (e.g., '/proc/self/fd/3'). """ cleanup_fds = [] - cert_path, key_path = None, None + print("--- in memfd_cert_key_paths") + paths = [] try: - if cert_bytes is not None: - # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. - fd_cert = os.memfd_create("mtls_cert", os.MFD_CLOEXEC) # type: ignore[attr-defined] - cleanup_fds.append(fd_cert) - with os.fdopen(fd_cert, "wb", closefd=False) as f: - f.write(cert_bytes) - cert_path = f"/proc/self/fd/{fd_cert}" - - if key_bytes is not None: - fd_key = os.memfd_create("mtls_key", os.MFD_CLOEXEC) # type: ignore[attr-defined] - cleanup_fds.append(fd_key) - with os.fdopen(fd_key, "wb", closefd=False) as f: - f.write(key_bytes) - key_path = f"/proc/self/fd/{fd_key}" + for data, name in [(cert_bytes, "mtls_cert"), (key_bytes, "mtls_key")]: + if data is not None: + # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. + fd = os.memfd_create(name, os.MFD_CLOEXEC) # type: ignore[attr-defined] + cleanup_fds.append(fd) + with os.fdopen(fd, "wb", closefd=False) as f: + f.write(data) + paths.append(f"/proc/self/fd/{fd}") + else: + paths.append(None) + cert_path, key_path = paths yield cert_path, key_path finally: # Closing the descriptors automatically frees the RAM allocation. @@ -258,32 +263,30 @@ def _tempfile_cert_key_paths( if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None ) - cert_path, key_path = None, None cleanup_files = [] + print("--- in _tempfile_cert_key_paths") new_passphrase = passphrase + cert_data = cert_bytes + key_data = None + if key_bytes is not None: + key_data, new_passphrase = _encrypt_key_if_plaintext(key_bytes, passphrase) + cert_path, key_path = None, None try: - if cert_bytes is not None: - fd, cert_path = tempfile.mkstemp(dir=tmp_dir) - cleanup_files.append(cert_path) - with os.fdopen(fd, "wb") as f: - f.write(cert_bytes) - f.flush() - os.fsync(f.fileno()) - - if key_bytes is not None: - # Encrypt plaintext keys on-the-fly before dropping to disk. - encrypted_key_bytes, new_passphrase = _encrypt_key_if_plaintext( - key_bytes, passphrase - ) - - fd, key_path = tempfile.mkstemp(dir=tmp_dir) - cleanup_files.append(key_path) - with os.fdopen(fd, "wb") as f: - f.write(encrypted_key_bytes) - f.flush() - os.fsync(f.fileno()) + paths = [] + for data in [cert_data, key_data]: + if data is not None: + fd, path = tempfile.mkstemp(dir=tmp_dir) + cleanup_files.append(path) + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + paths.append(path) + else: + paths.append(None) + cert_path, key_path = paths yield cert_path, key_path, new_passphrase finally: for file_path in cleanup_files: From 1e37bd66b6cab9c1811b75721afd5e53446510f0 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:45:32 -0700 Subject: [PATCH 12/25] refactor(auth): simplify fallback logic using custom exception and clean standard with block --- .../google/auth/transport/_mtls_helper.py | 45 ++++++++++--------- .../tests/transport/test__mtls_helper.py | 8 ++-- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 83c015539c24..94abd305d6e6 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -76,6 +76,12 @@ ) +class _MemfdCreationError(OSError): + """Raised when Linux in-memory virtual file creation (memfd) fails.""" + + pass + + @contextlib.contextmanager def secure_cert_key_paths( cert: Union[bytes, str, None], @@ -120,13 +126,8 @@ def secure_cert_key_paths( # the bytes to anonymous in-memory files using memfd_create. This yields # /proc/self/fd/... paths, keeping the private key entirely in memory. if sys.platform == "linux" and hasattr(os, "memfd_create"): - cm = _memfd_cert_key_paths(cert_bytes, key_bytes) try: - cert_path, key_path = cm.__enter__() - except OSError: - pass # Fallback to Tier 3 on failure. - else: - try: + with _memfd_cert_key_paths(cert_bytes, key_bytes) as (cert_path, key_path): # Handle cases where path exists but might be restricted. if (cert_path is None or os.path.exists(cert_path)) and ( key_path is None or os.path.exists(key_path) @@ -135,9 +136,8 @@ def secure_cert_key_paths( str, key_path or key ), passphrase return - finally: - cm.__exit__(*sys.exc_info()) - # If verification failed, fall through to Tier 3. + except _MemfdCreationError: + pass # Fallback to Tier 3 on failure. # Tier 3: Fallback Encrypted Temp Files. If in-memory files are not supported # (macOS/Windows), we write to disk. To protect the key, we encrypt plaintext @@ -219,20 +219,24 @@ def _memfd_cert_key_paths( the active descriptors (e.g., '/proc/self/fd/3'). """ cleanup_fds = [] - print("--- in memfd_cert_key_paths") paths = [] try: - for data, name in [(cert_bytes, "mtls_cert"), (key_bytes, "mtls_key")]: - if data is not None: - # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. - fd = os.memfd_create(name, os.MFD_CLOEXEC) # type: ignore[attr-defined] - cleanup_fds.append(fd) - with os.fdopen(fd, "wb", closefd=False) as f: - f.write(data) - paths.append(f"/proc/self/fd/{fd}") - else: - paths.append(None) + try: + for data, name in [(cert_bytes, "mtls_cert"), (key_bytes, "mtls_key")]: + if data is not None: + # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. + fd = os.memfd_create(name, os.MFD_CLOEXEC) # type: ignore[attr-defined] + cleanup_fds.append(fd) + with os.fdopen(fd, "wb", closefd=False) as f: + f.write(data) + paths.append(f"/proc/self/fd/{fd}") + else: + paths.append(None) + except OSError as exc: + raise _MemfdCreationError( + "Failed to create in-memory virtual files" + ) from exc cert_path, key_path = paths yield cert_path, key_path @@ -264,7 +268,6 @@ def _tempfile_cert_key_paths( else None ) cleanup_files = [] - print("--- in _tempfile_cert_key_paths") new_passphrase = passphrase cert_data = cert_bytes key_data = None diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 6cf61ee9c3e2..9d0f7a53180d 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -1099,7 +1099,9 @@ def test_tier2_fallback_to_tier3_on_oserror( self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create ): mock_memfd_ctx = mock.MagicMock() - mock_memfd_ctx.__enter__.side_effect = OSError("memfd failed") + mock_memfd_ctx.__enter__.side_effect = _mtls_helper._MemfdCreationError( + "memfd failed" + ) mock_memfd_cm.return_value = mock_memfd_ctx mock_tempfile_ctx = mock.MagicMock() @@ -1235,7 +1237,7 @@ def test_success_shm( with mock.patch.object(os, "remove") as mock_remove, mock.patch.object( os.path, "exists", return_value=True - ): + ), mock.patch.object(os, "access", return_value=True): with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass") as ( cert_path, key_path, @@ -1274,7 +1276,7 @@ def test_permission_error_loop_resilience( with mock.patch.object(os, "remove") as mock_remove, mock.patch.object( os.path, "exists", return_value=True - ): + ), mock.patch.object(os, "access", return_value=True): with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass"): pass mock_remove.assert_called_once_with("/shm/cert") From 2b14fbcd90f29c06bb4a561ae3d433bf3cb8c63c Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:49:11 -0700 Subject: [PATCH 13/25] refactor: add type annotations to paths variable in _mtls_helper.py --- packages/google-auth/google/auth/transport/_mtls_helper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 94abd305d6e6..5b0ceb8d6c65 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -23,7 +23,7 @@ import subprocess import sys import tempfile -from typing import cast, Generator, Optional, Tuple, Union +from typing import cast, Generator, List, Optional, Tuple, Union from google.auth import _agent_identity_utils from google.auth import environment_vars @@ -219,7 +219,7 @@ def _memfd_cert_key_paths( the active descriptors (e.g., '/proc/self/fd/3'). """ cleanup_fds = [] - paths = [] + paths: List[Optional[str]] = [] try: try: @@ -276,7 +276,7 @@ def _tempfile_cert_key_paths( cert_path, key_path = None, None try: - paths = [] + paths: List[Optional[str]] = [] for data in [cert_data, key_data]: if data is not None: fd, path = tempfile.mkstemp(dir=tmp_dir) From 6e4d6b2c07b1d529871db37009bf084b99a02037 Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 01:09:47 +0000 Subject: [PATCH 14/25] fix: unpack cryptography_base_require in DEPENDENCIES --- packages/google-auth/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google-auth/setup.py b/packages/google-auth/setup.py index 85902dbb32ce..5ac0a5e306c7 100644 --- a/packages/google-auth/setup.py +++ b/packages/google-auth/setup.py @@ -24,7 +24,7 @@ DEPENDENCIES = ( "pyasn1-modules>=0.2.1", - cryptography_base_require, + *cryptography_base_require, ) requests_extra_require = ["requests >= 2.20.0, < 3.0.0"] From c82de5c346449b916558c86e35a93d5ac70ceee6 Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 01:21:38 +0000 Subject: [PATCH 15/25] fix(auth): wrap callback, certificate load and trust chain read errors in RefreshError --- .../google-auth/google/auth/identity_pool.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 411cf7128cdf..84cda879225c 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -154,10 +154,13 @@ def __init__(self, trust_chain_path, leaf_cert_callback): def get_subject_token(self, context, request): from cryptography import x509 - leaf_cert_data = self._leaf_cert_callback() - if isinstance(leaf_cert_data, str): - leaf_cert_data = leaf_cert_data.encode("utf-8") - leaf_cert = x509.load_pem_x509_certificate(leaf_cert_data) + try: + leaf_cert_data = self._leaf_cert_callback() + if isinstance(leaf_cert_data, str): + leaf_cert_data = leaf_cert_data.encode("utf-8") + leaf_cert = x509.load_pem_x509_certificate(leaf_cert_data) + except Exception as e: + raise exceptions.RefreshError("Failed to parse leaf certificate.") from e trust_chain = self._read_trust_chain() cert_chain = [] @@ -210,10 +213,10 @@ def _read_trust_chain(self): ) ) from e return certificate_trust_chain - except FileNotFoundError: + except OSError as e: raise exceptions.RefreshError( - "Trust chain file '{}' was not found.".format(self._trust_chain_path) - ) + "Error accessing trust chain file '{}'.".format(self._trust_chain_path) + ) from e def _encode_cert(cert): from cryptography.hazmat.primitives import serialization From 40499753b39830de562bfa00c33f3fc4ea1bb7de Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 02:25:23 +0000 Subject: [PATCH 16/25] verify certificate path readability to prevent AppArmor/LSM crashes --- .../google/auth/transport/_mtls_helper.py | 59 +++--- .../tests/transport/test__mtls_helper.py | 191 +++++++++++++----- 2 files changed, 174 insertions(+), 76 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index 5b0ceb8d6c65..cfc19f099495 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -82,12 +82,23 @@ class _MemfdCreationError(OSError): pass +def _can_read(path: Optional[str]) -> bool: + if path is None: + return True + try: + with open(path, "rb"): + pass + return True + except OSError: + return False + + @contextlib.contextmanager def secure_cert_key_paths( cert: Union[bytes, str, None], key: Union[bytes, str, None], passphrase: Optional[bytes] = None, -) -> Generator[Tuple[str, str, Optional[bytes]], None, None]: +) -> Generator[Tuple[Optional[str], Optional[str], Optional[bytes]], None, None]: """Provides secure file paths for certificate and key. This function is implemented as a context manager generator to ensure that @@ -132,10 +143,11 @@ def secure_cert_key_paths( if (cert_path is None or os.path.exists(cert_path)) and ( key_path is None or os.path.exists(key_path) ): - yield cast(str, cert_path or cert), cast( - str, key_path or key - ), passphrase - return + if _can_read(cert_path) and _can_read(key_path): + yield cast(str, cert_path or cert), cast( + str, key_path or key + ), passphrase + return except _MemfdCreationError: pass # Fallback to Tier 3 on failure. @@ -267,39 +279,40 @@ def _tempfile_cert_key_paths( if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None ) - cleanup_files = [] + cleanup_files = [None, None] new_passphrase = passphrase cert_data = cert_bytes key_data = None if key_bytes is not None: key_data, new_passphrase = _encrypt_key_if_plaintext(key_bytes, passphrase) - cert_path, key_path = None, None try: - paths: List[Optional[str]] = [] - for data in [cert_data, key_data]: + for i, data in enumerate([cert_data, key_data]): if data is not None: - fd, path = tempfile.mkstemp(dir=tmp_dir) - cleanup_files.append(path) + try: + fd, path = tempfile.mkstemp(dir=tmp_dir) + except OSError: + fd, path = tempfile.mkstemp(dir=None) + cleanup_files[i] = path with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) - paths.append(path) - else: - paths.append(None) - cert_path, key_path = paths - yield cert_path, key_path, new_passphrase + yield cleanup_files[0], cleanup_files[1], new_passphrase finally: - for file_path in cleanup_files: + cert_cleanup_path = cleanup_files[0] + key_cleanup_path = cleanup_files[1] + + if key_cleanup_path: try: - # Wiping the private key with null bytes before removal. - if file_path == key_path: - _secure_wipe_and_remove(file_path) - else: - if os.path.exists(file_path): - os.remove(file_path) + _secure_wipe_and_remove(key_cleanup_path) + except OSError: + pass + if cert_cleanup_path: + try: + if os.path.exists(cert_cleanup_path): + os.remove(cert_cleanup_path) except OSError: pass diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 9d0f7a53180d..af07ffd9de18 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -1041,7 +1041,7 @@ def test_tier1_pass_through(self): @mock.patch.object(sys, "platform", "linux") @mock.patch.object(os, "memfd_create", create=True) @mock.patch.object(_mtls_helper, "_memfd_cert_key_paths", autospec=True) - def test_tier2_memfd_success(self, mock_memfd_cm, mock_memfd_create): + def test_memfd_success(self, mock_memfd_cm, mock_memfd_create): mock_memfd_ctx = mock.MagicMock() mock_memfd_ctx.__enter__.return_value = ( "/proc/self/fd/3", @@ -1049,7 +1049,9 @@ def test_tier2_memfd_success(self, mock_memfd_cm, mock_memfd_create): ) mock_memfd_cm.return_value = mock_memfd_ctx - with mock.patch.object(os.path, "exists", return_value=True): + with mock.patch.object(os.path, "exists", return_value=True), mock.patch( + "builtins.open", mock.mock_open() + ): with _mtls_helper.secure_cert_key_paths( pytest.public_cert_bytes, pytest.private_key_bytes, @@ -1064,7 +1066,7 @@ def test_tier2_memfd_success(self, mock_memfd_cm, mock_memfd_create): @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_restricted_filesystem( + def test_falls_back_to_tempfile_when_filesystem_restricted( self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create ): mock_memfd_ctx = mock.MagicMock() @@ -1095,7 +1097,43 @@ def test_tier2_restricted_filesystem( @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( + def test_falls_back_to_tempfile_when_filesystem_unreadable( + self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create + ): + mock_memfd_ctx = mock.MagicMock() + mock_memfd_ctx.__enter__.return_value = ( + "/proc/self/fd/3", + "/proc/self/fd/4", + ) + mock_memfd_cm.return_value = mock_memfd_ctx + + mock_tempfile_ctx = mock.MagicMock() + mock_tempfile_ctx.__enter__.return_value = ( + "/tmp/cert", + "/tmp/key", + b"new_pass", + ) + mock_tempfile_cm.return_value = mock_tempfile_ctx + + with mock.patch.object(os.path, "exists", return_value=True), mock.patch( + "builtins.open", mock.mock_open() + ) as mock_open: + mock_open.side_effect = PermissionError("Permission denied") + + with _mtls_helper.secure_cert_key_paths( + pytest.public_cert_bytes, pytest.private_key_bytes, b"passphrase" + ) as (cert_path, key_path, passphrase): + assert cert_path == "/tmp/cert" + assert key_path == "/tmp/key" + assert passphrase == b"new_pass" + + mock_memfd_ctx.__exit__.assert_called_once_with(None, None, None) + + @mock.patch.object(sys, "platform", "linux") + @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_falls_back_to_tempfile_when_memfd_fails( self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create ): mock_memfd_ctx = mock.MagicMock() @@ -1121,7 +1159,7 @@ def test_tier2_fallback_to_tier3_on_oserror( @mock.patch.object(sys, "platform", "darwin") @mock.patch.object(_mtls_helper, "_tempfile_cert_key_paths", autospec=True) - def test_tier3_tempfile_success_non_linux(self, mock_tempfile_cm): + def test_uses_tempfile_directly_on_unsupported_os(self, mock_tempfile_cm): mock_tempfile_ctx = mock.MagicMock() mock_tempfile_ctx.__enter__.return_value = ( "/tmp/cert", @@ -1162,10 +1200,8 @@ class TestMemfdCertKeyPaths(object): @mock.patch.object(os, "close") def test_success_both_bytes(self, mock_close, mock_fdopen, mock_memfd_create): mock_memfd_create.side_effect = [10, 11] - mock_file_cert = mock.MagicMock() - mock_file_cert.__enter__.return_value = mock_file_cert - mock_file_key = mock.MagicMock() - mock_file_key.__enter__.return_value = mock_file_key + mock_file_cert = mock.mock_open().return_value + mock_file_key = mock.mock_open().return_value mock_fdopen.side_effect = [mock_file_cert, mock_file_key] with _mtls_helper._memfd_cert_key_paths(b"cert", b"key") as ( cert_path, @@ -1186,8 +1222,7 @@ def test_success_both_bytes(self, mock_close, mock_fdopen, mock_memfd_create): def test_close_ignores_oserror(self, mock_close, mock_fdopen, mock_memfd_create): mock_memfd_create.return_value = 12 mock_close.side_effect = OSError("close error") - mock_file = mock.MagicMock() - mock_file.__enter__.return_value = mock_file + mock_file = mock.mock_open().return_value mock_fdopen.return_value = mock_file with _mtls_helper._memfd_cert_key_paths(b"cert", None) as (cert_path, key_path): assert cert_path == "/proc/self/fd/12" @@ -1203,8 +1238,7 @@ def test_write_oserror_prevents_fd_leak( self, mock_close, mock_fdopen, mock_memfd_create ): mock_memfd_create.return_value = 15 - mock_file = mock.MagicMock() - mock_file.__enter__.return_value = mock_file + mock_file = mock.mock_open().return_value mock_file.write.side_effect = OSError("write fault") mock_fdopen.return_value = mock_file with pytest.raises(OSError): @@ -1217,69 +1251,120 @@ def test_write_oserror_prevents_fd_leak( class TestTempfileCertKeyPaths(object): @mock.patch.object(os.path, "isdir", return_value=True) - @mock.patch.object(tempfile, "mkstemp") - @mock.patch.object(os, "fdopen") @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) - @mock.patch.object(_mtls_helper, "_secure_wipe_and_remove", autospec=True) def test_success_shm( self, - mock_wipe, mock_encrypt, - mock_fdopen, - mock_mkstemp, mock_isdir, + tmpdir, ): - mock_mkstemp.side_effect = [(1, "/shm/cert"), (2, "/shm/key")] - mock_encrypt.return_value = (b"encrypted_key", b"new_pass") - mock_file = mock.MagicMock() - mock_file.fileno.return_value = 1 - mock_fdopen.return_value.__enter__.return_value = mock_file - - with mock.patch.object(os, "remove") as mock_remove, mock.patch.object( - os.path, "exists", return_value=True - ), mock.patch.object(os, "access", return_value=True): + original_mkstemp = tempfile.mkstemp + + def _redirect_mkstemp(dir=None): + return original_mkstemp(dir=str(tmpdir)) + + with mock.patch.object( + tempfile, "mkstemp", side_effect=_redirect_mkstemp + ) as mock_mkstemp: + mock_encrypt.return_value = (b"encrypted_key", b"new_pass") + with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass") as ( cert_path, key_path, passphrase, ): - assert cert_path == "/shm/cert" - assert key_path == "/shm/key" + assert cert_path.startswith(str(tmpdir)) + assert os.path.exists(cert_path) assert passphrase == b"new_pass" - mock_remove.assert_called_once_with("/shm/cert") - mock_mkstemp.assert_has_calls( - [mock.call(dir="/dev/shm"), mock.call(dir="/dev/shm")] - ) - mock_wipe.assert_called_once_with("/shm/key") + with open(cert_path, "rb") as f: + assert f.read() == b"cert" + + with open(key_path, "rb") as f: + assert f.read() == b"encrypted_key" + + # Organically verify secure cleanup occurred + assert not os.path.exists(cert_path) + assert not os.path.exists(key_path) + + mock_mkstemp.assert_has_calls( + [mock.call(dir="/dev/shm"), mock.call(dir="/dev/shm")] + ) + + @mock.patch.object(os.path, "isdir", return_value=True) + @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) + def test_mkstemp_shm_oserror_fallback( + self, + mock_encrypt, + mock_isdir, + tmpdir, + ): + original_mkstemp = tempfile.mkstemp + call_count = [0] + + def _redirect_mkstemp(dir=None): + call_count[0] += 1 + if call_count[0] % 2 != 0: + raise OSError("No space left on device") + return original_mkstemp(dir=str(tmpdir)) + + with mock.patch.object( + tempfile, "mkstemp", side_effect=_redirect_mkstemp + ) as mock_mkstemp: + mock_encrypt.return_value = (b"encrypted_key", b"new_pass") + + with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass") as ( + cert_path, + key_path, + passphrase, + ): + assert cert_path.startswith(str(tmpdir)) + assert os.path.exists(cert_path) + assert passphrase == b"new_pass" + + mock_mkstemp.assert_has_calls( + [ + mock.call(dir="/dev/shm"), + mock.call(dir=None), + mock.call(dir="/dev/shm"), + mock.call(dir=None), + ] + ) + + assert not os.path.exists(cert_path) + assert not os.path.exists(key_path) @mock.patch.object(os.path, "isdir", return_value=True) - @mock.patch.object(tempfile, "mkstemp") - @mock.patch.object(os, "fdopen") @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) @mock.patch.object(_mtls_helper, "_secure_wipe_and_remove", autospec=True) def test_permission_error_loop_resilience( self, mock_wipe, mock_encrypt, - mock_fdopen, - mock_mkstemp, mock_isdir, + tmpdir, ): - mock_mkstemp.side_effect = [(1, "/shm/cert"), (2, "/shm/key")] - mock_encrypt.return_value = (b"encrypted_key", b"new_pass") - mock_file = mock.MagicMock() - mock_file.fileno.return_value = 1 - mock_fdopen.return_value.__enter__.return_value = mock_file - - mock_wipe.side_effect = PermissionError("lock error") - - with mock.patch.object(os, "remove") as mock_remove, mock.patch.object( - os.path, "exists", return_value=True - ), mock.patch.object(os, "access", return_value=True): - with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass"): - pass - mock_remove.assert_called_once_with("/shm/cert") + original_mkstemp = tempfile.mkstemp + + def _redirect_mkstemp(dir=None): + return original_mkstemp(dir=str(tmpdir)) + + with mock.patch.object(tempfile, "mkstemp", side_effect=_redirect_mkstemp): + mock_encrypt.return_value = (b"encrypted_key", b"new_pass") + + # Mock the secure wipe to fail with PermissionError to test resilience + mock_wipe.side_effect = PermissionError("lock error") + + with _mtls_helper._tempfile_cert_key_paths(b"cert", b"key", b"pass") as ( + cert_path, + key_path, + passphrase, + ): + assert os.path.exists(cert_path) + assert os.path.exists(key_path) + + # Organically verify cert_path was cleaned up despite PermissionError on key + assert not os.path.exists(cert_path) class TestEncryptKeyIfPlaintext(object): From 0f53316643641b8b49b1c9d20b771696354b9af7 Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 02:26:05 +0000 Subject: [PATCH 17/25] wrap secure_cert_key_paths inside transport exception handlers --- .../google/auth/aio/transport/mtls.py | 20 +++++++++---------- .../google/auth/transport/requests.py | 20 +++++++++---------- .../google/auth/transport/urllib3.py | 20 +++++++++---------- .../tests/transport/test_aio_mtls_helper.py | 15 ++++++++++++++ .../tests/transport/test_requests.py | 17 ++++++++++++++++ .../tests/transport/test_urllib3.py | 17 ++++++++++++++++ 6 files changed, 79 insertions(+), 30 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/mtls.py b/packages/google-auth/google/auth/aio/transport/mtls.py index bbbd39ff1950..10d08af5858e 100644 --- a/packages/google-auth/google/auth/aio/transport/mtls.py +++ b/packages/google-auth/google/auth/aio/transport/mtls.py @@ -46,12 +46,12 @@ def make_client_cert_ssl_context( Raises: google.auth.exceptions.TransportError: If there is an error loading the certificate. """ - with secure_cert_key_paths(cert_bytes, key_bytes, passphrase=passphrase) as ( - cert_path, - key_path, - passphrase_val, - ): - try: + try: + with secure_cert_key_paths(cert_bytes, key_bytes, passphrase=passphrase) as ( + cert_path, + key_path, + passphrase_val, + ): context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) context.load_cert_chain( certfile=cert_path, @@ -59,10 +59,10 @@ def make_client_cert_ssl_context( password=passphrase_val or "", ) return context - except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: - raise exceptions.TransportError( - "Failed to load client certificate and key for mTLS." - ) from exc + except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: + raise exceptions.TransportError( + "Failed to load client certificate and key for mTLS." + ) from exc async def _run_in_executor(func, *args): diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index 89945d536263..91ac8d5196dc 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -217,12 +217,12 @@ def __init__(self, cert, key): ctx_proxymanager = create_urllib3_context() ctx_proxymanager.load_verify_locations(cafile=certifi.where()) - with _mtls_helper.secure_cert_key_paths(cert, key) as ( - cert_path, - key_path, - passphrase, - ): - try: + try: + with _mtls_helper.secure_cert_key_paths(cert, key) as ( + cert_path, + key_path, + passphrase, + ): ctx_poolmanager.load_cert_chain( certfile=cert_path, keyfile=key_path, @@ -233,10 +233,10 @@ def __init__(self, cert, key): keyfile=key_path, password=passphrase or "", ) - except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: - raise exceptions.MutualTLSChannelError( - "Failed to configure client certificate and key for mTLS." - ) from exc + except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: + raise exceptions.MutualTLSChannelError( + "Failed to configure client certificate and key for mTLS." + ) from exc self._ctx_poolmanager = ctx_poolmanager self._ctx_proxymanager = ctx_proxymanager diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index 022c5708ba04..9be1fb0a7320 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -182,21 +182,21 @@ def _make_mutual_tls_http(cert, key): ctx = urllib3.util.ssl_.create_urllib3_context() ctx.load_verify_locations(cafile=certifi.where()) - with _mtls_helper.secure_cert_key_paths(cert, key) as ( - cert_path, - key_path, - passphrase, - ): - try: + try: + with _mtls_helper.secure_cert_key_paths(cert, key) as ( + cert_path, + key_path, + passphrase, + ): ctx.load_cert_chain( certfile=cert_path, keyfile=key_path, password=passphrase or "", ) - except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: - raise exceptions.MutualTLSChannelError( - "Failed to configure client certificate and key for mTLS." - ) from exc + except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: + raise exceptions.MutualTLSChannelError( + "Failed to configure client certificate and key for mTLS." + ) from exc http = urllib3.PoolManager(ssl_context=ctx) return http diff --git a/packages/google-auth/tests/transport/test_aio_mtls_helper.py b/packages/google-auth/tests/transport/test_aio_mtls_helper.py index 2af155d9ee83..6ec1b9028098 100644 --- a/packages/google-auth/tests/transport/test_aio_mtls_helper.py +++ b/packages/google-auth/tests/transport/test_aio_mtls_helper.py @@ -182,3 +182,18 @@ async def test_get_client_ssl_credentials_error(self, mock_workload): with pytest.raises(exceptions.ClientCertError, match="Failed to read metadata"): await mtls.get_client_ssl_credentials() + + @pytest.mark.asyncio + @mock.patch("google.auth.aio.transport.mtls.secure_cert_key_paths") + async def test_make_client_cert_ssl_context_setup_error(self, mock_secure_paths): + """Verifies that TransportError is raised when temp file creation fails.""" + cert_bytes = b"cert_data" + key_bytes = b"key_data" + + mock_secure_paths.side_effect = OSError("Temp file error") + + with pytest.raises(exceptions.TransportError) as exc_info: + mtls.make_client_cert_ssl_context(cert_bytes, key_bytes) + + assert "Failed to load client certificate" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, OSError) diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index 673119493a0b..e761a12a6bca 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -195,6 +195,14 @@ def test_invalid_cert_or_key(self): b"invalid cert", b"invalid key" ) + @mock.patch("google.auth.transport.requests._mtls_helper.secure_cert_key_paths") + def test_setup_error_raises_mutual_tls_channel_error(self, mock_secure_paths): + mock_secure_paths.side_effect = OSError("Disk full") + with pytest.raises(exceptions.MutualTLSChannelError) as exc_info: + google.auth.transport.requests._MutualTlsAdapter(b"cert", b"key") + assert "Failed to configure client certificate" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, OSError) + def make_response(status=http_client.OK, data=None): response = requests.Response() @@ -523,6 +531,15 @@ def test_configure_mtls_channel_cert_loading_exceptions( @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) + @mock.patch.dict( + os.environ, + { + "GOOGLE_API_USE_CLIENT_CERTIFICATE": "false", + "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false", + "GOOGLE_API_CERTIFICATE_CONFIG": "", + "CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH": "", + }, + ) def test_configure_mtls_channel_without_client_cert_env( self, get_client_cert_and_key ): diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 8ebcacf6d02c..33674030aa8d 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -106,6 +106,14 @@ def test_crypto_error(self): b"invalid cert", b"invalid key" ) + @mock.patch("google.auth.transport.urllib3._mtls_helper.secure_cert_key_paths") + def test_setup_error_raises_mutual_tls_channel_error(self, mock_secure_paths): + mock_secure_paths.side_effect = OSError("Disk full") + with pytest.raises(exceptions.MutualTLSChannelError) as exc_info: + google.auth.transport.urllib3._make_mutual_tls_http(b"cert", b"key") + assert "Failed to configure client certificate" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, OSError) + class TestAuthorizedHttp(object): TEST_URL = "http://example.com" @@ -323,6 +331,15 @@ def test_configure_mtls_channel_cert_loading_exceptions( @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) + @mock.patch.dict( + os.environ, + { + "GOOGLE_API_USE_CLIENT_CERTIFICATE": "false", + "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false", + "GOOGLE_API_CERTIFICATE_CONFIG": "", + "CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH": "", + }, + ) def test_configure_mtls_channel_without_client_cert_env( self, get_client_cert_and_key ): From 3fff58d98bf56505c9b56faa6c8eb118caaf6ef5 Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 03:13:32 +0000 Subject: [PATCH 18/25] fix: catch ConnectionError in MDS client to allow HTTP fallback on connection resets --- .../google/auth/compute_engine/_mtls.py | 1 + .../tests/compute_engine/test__mtls.py | 30 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/google-auth/google/auth/compute_engine/_mtls.py b/packages/google-auth/google/auth/compute_engine/_mtls.py index d475c1e59a9e..865f29140f1a 100644 --- a/packages/google-auth/google/auth/compute_engine/_mtls.py +++ b/packages/google-auth/google/auth/compute_engine/_mtls.py @@ -146,6 +146,7 @@ def send(self, request, **kwargs): ssl.SSLError, requests.exceptions.SSLError, requests.exceptions.HTTPError, + requests.exceptions.ConnectionError, ) as e: _LOGGER.warning( "mTLS connection to Compute Engine Metadata server failed. " diff --git a/packages/google-auth/tests/compute_engine/test__mtls.py b/packages/google-auth/tests/compute_engine/test__mtls.py index eb7b919fd374..d984eae605b3 100644 --- a/packages/google-auth/tests/compute_engine/test__mtls.py +++ b/packages/google-auth/tests/compute_engine/test__mtls.py @@ -250,6 +250,31 @@ def test_mds_mtls_adapter_send_fallback_http_error( assert fallback_request.url == "http://fake-mds.com/" +@mock.patch("google.auth.compute_engine._mtls.HTTPAdapter") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_fallback_connection_error( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_class, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + mock_fallback_send = mock.Mock() + mock_http_adapter_class.return_value.send = mock_fallback_send + + with mock.patch( + "requests.adapters.HTTPAdapter.send", + side_effect=requests.exceptions.ConnectionError, + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + adapter.send(request) + + mock_http_adapter_class.assert_called_once() + mock_fallback_send.assert_called_once() + fallback_request = mock_fallback_send.call_args[0][0] + assert fallback_request.url == "http://fake-mds.com/" + + @mock.patch("requests.adapters.HTTPAdapter.send") @mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") @mock.patch("ssl.create_default_context") @@ -259,13 +284,12 @@ def test_mds_mtls_adapter_send_no_fallback_other_exception( mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) - # Simulate HTTP exception with mock.patch( "requests.adapters.HTTPAdapter.send", - side_effect=requests.exceptions.ConnectionError, + side_effect=requests.exceptions.Timeout, ): request = requests.Request(method="GET", url="https://fake-mds.com").prepare() - with pytest.raises(requests.exceptions.ConnectionError): + with pytest.raises(requests.exceptions.Timeout): adapter.send(request) mock_http_adapter_send.assert_not_called() From 38fb4f837faeaec891ec670d54b55a9900273e6e Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 03:13:50 +0000 Subject: [PATCH 19/25] raise MutualTLSChannelError if custom TLS signer is used on unsupported runtimes --- .../auth/transport/_custom_tls_signer.py | 4 ++ .../transport/test__custom_tls_signer.py | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/google-auth/google/auth/transport/_custom_tls_signer.py b/packages/google-auth/google/auth/transport/_custom_tls_signer.py index 1ac0d081e2da..3d31bad50b48 100644 --- a/packages/google-auth/google/auth/transport/_custom_tls_signer.py +++ b/packages/google-auth/google/auth/transport/_custom_tls_signer.py @@ -45,6 +45,10 @@ # Cast SSL_CTX* to void* def _cast_ssl_ctx_to_void_p_stdlib(context): + if sys.implementation.name != "cpython" or hasattr(sys, "getobjects"): + raise exceptions.MutualTLSChannelError( + "Custom TLS signing is only supported on standard release CPython runtimes." + ) return ctypes.c_void_p.from_address( id(context) + ctypes.sizeof(ctypes.c_void_p) * 2 ) diff --git a/packages/google-auth/tests/transport/test__custom_tls_signer.py b/packages/google-auth/tests/transport/test__custom_tls_signer.py index c0e40466e17e..a0df9affa756 100644 --- a/packages/google-auth/tests/transport/test__custom_tls_signer.py +++ b/packages/google-auth/tests/transport/test__custom_tls_signer.py @@ -14,6 +14,7 @@ import base64 import ctypes import os +import sys from unittest import mock import pytest # type: ignore @@ -258,3 +259,40 @@ def test_custom_tls_signer_failed_to_attach_no_libs(): signer_object._signer_lib = None signer_object.attach_to_ssl_context(mock.MagicMock()) assert excinfo.match("Invalid ECP configuration.") + + +def test_cast_ssl_ctx_to_void_p_stdlib_success(): + context = mock.MagicMock() + with mock.patch.object(sys.implementation, "name", "cpython"): + if hasattr(sys, "getobjects"): + with mock.patch.delattr(sys, "getobjects"): + res = _custom_tls_signer._cast_ssl_ctx_to_void_p_stdlib(context) + assert isinstance(res, ctypes.c_void_p) + else: + res = _custom_tls_signer._cast_ssl_ctx_to_void_p_stdlib(context) + assert isinstance(res, ctypes.c_void_p) + + +def test_cast_ssl_ctx_to_void_p_stdlib_unsupported_runtime_pypy(): + context = mock.MagicMock() + + with mock.patch.object(sys.implementation, "name", "pypy"): + with pytest.raises( + exceptions.MutualTLSChannelError, + match="Custom TLS signing is only supported", + ): + _custom_tls_signer._cast_ssl_ctx_to_void_p_stdlib(context) + + +def test_cast_ssl_ctx_to_void_p_stdlib_unsupported_runtime_trace_refs(): + context = mock.MagicMock() + + with mock.patch.object(sys.implementation, "name", "cpython"), mock.patch( + "sys.getobjects", create=True + ): + with pytest.raises( + exceptions.MutualTLSChannelError, + match="Custom TLS signing is only supported", + ): + _custom_tls_signer._cast_ssl_ctx_to_void_p_stdlib(context) + From 567b90a74d5ea82925c2283cd92777a81decac78 Mon Sep 17 00:00:00 2001 From: Negar Bayati Date: Thu, 25 Jun 2026 03:34:47 +0000 Subject: [PATCH 20/25] fix lint and mypy failre --- .../google-auth/google/auth/aio/transport/mtls.py | 11 ++++++----- .../google-auth/google/auth/transport/_mtls_helper.py | 2 +- .../tests/transport/test__custom_tls_signer.py | 1 - 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/mtls.py b/packages/google-auth/google/auth/aio/transport/mtls.py index 10d08af5858e..085a2799585e 100644 --- a/packages/google-auth/google/auth/aio/transport/mtls.py +++ b/packages/google-auth/google/auth/aio/transport/mtls.py @@ -53,11 +53,12 @@ def make_client_cert_ssl_context( passphrase_val, ): context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - context.load_cert_chain( - certfile=cert_path, - keyfile=key_path, - password=passphrase_val or "", - ) + if cert_path: + context.load_cert_chain( + certfile=cert_path, + keyfile=key_path, + password=passphrase_val or "", + ) return context except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: raise exceptions.TransportError( diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index cfc19f099495..bf24a0ee6ec2 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -279,7 +279,7 @@ def _tempfile_cert_key_paths( if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None ) - cleanup_files = [None, None] + cleanup_files: List[Optional[str]] = [None, None] new_passphrase = passphrase cert_data = cert_bytes key_data = None diff --git a/packages/google-auth/tests/transport/test__custom_tls_signer.py b/packages/google-auth/tests/transport/test__custom_tls_signer.py index a0df9affa756..b7c3914ffb19 100644 --- a/packages/google-auth/tests/transport/test__custom_tls_signer.py +++ b/packages/google-auth/tests/transport/test__custom_tls_signer.py @@ -295,4 +295,3 @@ def test_cast_ssl_ctx_to_void_p_stdlib_unsupported_runtime_trace_refs(): match="Custom TLS signing is only supported", ): _custom_tls_signer._cast_ssl_ctx_to_void_p_stdlib(context) - From f86e92690b341ec6c0e689044dbba06865245c8a Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:20:13 -0700 Subject: [PATCH 21/25] fix(auth): handle FileNotFoundError for trust chain and mock os.access in tests --- packages/google-auth/google/auth/identity_pool.py | 4 ++++ packages/google-auth/tests/transport/test__mtls_helper.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 84cda879225c..3577d053ec92 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -213,6 +213,10 @@ def _read_trust_chain(self): ) ) from e return certificate_trust_chain + except FileNotFoundError as e: + raise exceptions.RefreshError( + "Trust chain file '{}' was not found.".format(self._trust_chain_path) + ) from e except OSError as e: raise exceptions.RefreshError( "Error accessing trust chain file '{}'.".format(self._trust_chain_path) diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index af07ffd9de18..900afb7b5208 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -1250,12 +1250,14 @@ def test_write_oserror_prevents_fd_leak( class TestTempfileCertKeyPaths(object): + @mock.patch.object(os, "access", return_value=True) @mock.patch.object(os.path, "isdir", return_value=True) @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) def test_success_shm( self, mock_encrypt, mock_isdir, + mock_access, tmpdir, ): original_mkstemp = tempfile.mkstemp @@ -1291,12 +1293,14 @@ def _redirect_mkstemp(dir=None): [mock.call(dir="/dev/shm"), mock.call(dir="/dev/shm")] ) + @mock.patch.object(os, "access", return_value=True) @mock.patch.object(os.path, "isdir", return_value=True) @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) def test_mkstemp_shm_oserror_fallback( self, mock_encrypt, mock_isdir, + mock_access, tmpdir, ): original_mkstemp = tempfile.mkstemp @@ -1334,6 +1338,7 @@ def _redirect_mkstemp(dir=None): assert not os.path.exists(cert_path) assert not os.path.exists(key_path) + @mock.patch.object(os, "access", return_value=True) @mock.patch.object(os.path, "isdir", return_value=True) @mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True) @mock.patch.object(_mtls_helper, "_secure_wipe_and_remove", autospec=True) @@ -1342,6 +1347,7 @@ def test_permission_error_loop_resilience( mock_wipe, mock_encrypt, mock_isdir, + mock_access, tmpdir, ): original_mkstemp = tempfile.mkstemp From fdebe2fd6c7628bb01e7327d167e33881272b92c Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:26:16 -0700 Subject: [PATCH 22/25] refactor(auth): remove redundant import and passphrase type fallback in transport adapters --- packages/google-auth/google/auth/transport/_mtls_helper.py | 2 -- packages/google-auth/google/auth/transport/requests.py | 4 ++-- packages/google-auth/google/auth/transport/urllib3.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index bf24a0ee6ec2..c19dcbeef783 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -201,8 +201,6 @@ def _secure_wipe_and_remove(file_path: str): the original private key bytes might still physically remain on the storage chips until the drive cleans them up. """ - import os - if not os.path.exists(file_path): return try: diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index 91ac8d5196dc..9b05a4a69d6d 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -226,12 +226,12 @@ def __init__(self, cert, key): ctx_poolmanager.load_cert_chain( certfile=cert_path, keyfile=key_path, - password=passphrase or "", + password=passphrase, ) ctx_proxymanager.load_cert_chain( certfile=cert_path, keyfile=key_path, - password=passphrase or "", + password=passphrase, ) except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: raise exceptions.MutualTLSChannelError( diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index 9be1fb0a7320..0289a480725e 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -191,7 +191,7 @@ def _make_mutual_tls_http(cert, key): ctx.load_cert_chain( certfile=cert_path, keyfile=key_path, - password=passphrase or "", + password=passphrase, ) except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc: raise exceptions.MutualTLSChannelError( From 342100a3911f93727c57a4c614eefc817253b2ec Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:26:23 -0700 Subject: [PATCH 23/25] docs(auth): document plaintext fallback behavior in mtls helper key encryption --- packages/google-auth/google/auth/transport/_mtls_helper.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index c19dcbeef783..6fba5b578195 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -167,7 +167,9 @@ def _encrypt_key_if_plaintext( ) -> Tuple[bytes, Optional[bytes]]: """Encrypts a plaintext PEM key if necessary, returning the bytes and passphrase. - If the key is already encrypted, returns it as-is. + If the key is already encrypted, or if parsing/encryption fails, the key is + returned as-is (plaintext) as a fallback. This allows the caller (underlying SSL + context) to attempt loading the key directly and handle any failures. """ from cryptography.hazmat.primitives import serialization import secrets From 13c4c77b16a294c4463df14b80ea6753907b9e07 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:02:59 -0700 Subject: [PATCH 24/25] fix(auth): call leaf cert callback outside of parse try block --- packages/google-auth/google/auth/identity_pool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 3577d053ec92..376f96d44370 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -156,6 +156,10 @@ def get_subject_token(self, context, request): try: leaf_cert_data = self._leaf_cert_callback() + except Exception as e: + raise exceptions.RefreshError("Failed to retrieve leaf certificate.") from e + + try: if isinstance(leaf_cert_data, str): leaf_cert_data = leaf_cert_data.encode("utf-8") leaf_cert = x509.load_pem_x509_certificate(leaf_cert_data) From f0e27ca05464cca78dd5332c5cbcd9647d366da9 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:28:02 -0700 Subject: [PATCH 25/25] refactor: extract _encode_cert to module level in identity_pool --- .../google-auth/google/auth/identity_pool.py | 19 ++++++++++--------- .../google/auth/transport/requests.py | 1 - .../google/auth/transport/urllib3.py | 1 - 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/google-auth/google/auth/identity_pool.py b/packages/google-auth/google/auth/identity_pool.py index 376f96d44370..4b1aa393b2fa 100644 --- a/packages/google-auth/google/auth/identity_pool.py +++ b/packages/google-auth/google/auth/identity_pool.py @@ -158,7 +158,7 @@ def get_subject_token(self, context, request): leaf_cert_data = self._leaf_cert_callback() except Exception as e: raise exceptions.RefreshError("Failed to retrieve leaf certificate.") from e - + try: if isinstance(leaf_cert_data, str): leaf_cert_data = leaf_cert_data.encode("utf-8") @@ -168,18 +168,18 @@ def get_subject_token(self, context, request): trust_chain = self._read_trust_chain() cert_chain = [] - cert_chain.append(_X509Supplier._encode_cert(leaf_cert)) + cert_chain.append(_encode_cert(leaf_cert)) if trust_chain is None or len(trust_chain) == 0: return json.dumps(cert_chain) # Append the first cert if it is not the leaf cert. - first_cert = _X509Supplier._encode_cert(trust_chain[0]) + first_cert = _encode_cert(trust_chain[0]) if first_cert != cert_chain[0]: cert_chain.append(first_cert) for i in range(1, len(trust_chain)): - encoded = _X509Supplier._encode_cert(trust_chain[i]) + encoded = _encode_cert(trust_chain[i]) # Check if the current cert is the leaf cert and raise an exception if it is. if encoded == cert_chain[0]: raise exceptions.RefreshError( @@ -226,12 +226,13 @@ def _read_trust_chain(self): "Error accessing trust chain file '{}'.".format(self._trust_chain_path) ) from e - def _encode_cert(cert): - from cryptography.hazmat.primitives import serialization - return base64.b64encode(cert.public_bytes(serialization.Encoding.DER)).decode( - "utf-8" - ) +def _encode_cert(cert): + from cryptography.hazmat.primitives import serialization + + return base64.b64encode(cert.public_bytes(serialization.Encoding.DER)).decode( + "utf-8" + ) def _parse_token_data(token_content, format_type="text", subject_token_field_name=None): diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index 9b05a4a69d6d..dcc2b9fe1358 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -457,7 +457,6 @@ def configure_mtls_channel(self, client_cert_callback=None): if not use_client_cert: return - try: ( is_mtls, diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index 0289a480725e..f98a619cbaa9 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -346,7 +346,6 @@ def configure_mtls_channel(self, client_cert_callback=None): if not use_client_cert: return False - try: found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key( client_cert_callback