1# Copyright 2020 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Helper functions for getting mTLS cert and key."""
16
17import contextlib
18import json
19import logging
20import os
21import re
22import subprocess
23import sys
24import tempfile
25from os import environ, getenv, path
26from typing import Generator, List, Optional, Tuple, Union, cast
27from urllib.parse import urlsplit
28
29from google.auth import _agent_identity_utils, _cloud_sdk, environment_vars, exceptions
30
31CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json"
32
33# Default gcloud config path, to be used with path.expanduser for cross-platform compatibility.
34CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json"
35_GKE_CREDENTIAL_BUNDLE_PATH = "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem"
36_CERT_PROVIDER_COMMAND = "cert_provider_command"
37_CERT_REGEX = re.compile(
38 b"-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----\r?\n?", re.DOTALL
39)
40
41# support various format of key files, e.g.
42# "-----BEGIN PRIVATE KEY-----...",
43# "-----BEGIN EC PRIVATE KEY-----...",
44# "-----BEGIN RSA PRIVATE KEY-----..."
45# "-----BEGIN ENCRYPTED PRIVATE KEY-----"
46_KEY_REGEX = re.compile(
47 b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+?-----END [A-Z ]*PRIVATE KEY-----\r?\n?",
48 re.DOTALL,
49)
50
51_LOGGER = logging.getLogger(__name__)
52
53
54_PASSPHRASE_REGEX = re.compile(
55 b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL
56)
57
58
59class _MemfdCreationError(OSError):
60 """Raised when Linux in-memory virtual file creation (memfd) fails."""
61
62 pass
63
64
65def _can_read(path: Optional[str]) -> bool:
66 if path is None:
67 return True
68 try:
69 with open(path, "rb"):
70 pass
71 return True
72 except OSError:
73 return False
74
75
76@contextlib.contextmanager
77def secure_cert_key_paths(
78 cert: Union[bytes, str, None],
79 key: Union[bytes, str, None],
80 passphrase: Optional[bytes] = None,
81) -> Generator[Tuple[Optional[str], Optional[str], Optional[bytes]], None, None]:
82 """Provides secure file paths for certificate and key.
83
84 This function is implemented as a context manager generator to ensure that
85 any temporary resources (such as in-memory virtual files or encrypted physical
86 temp files) are automatically cleaned up and securely wiped when the context exits.
87
88 It supports mixed inputs (e.g. passing one as a string path and the other as bytes).
89 If a parameter is already a string path or None, it is passed through as-is, and
90 only raw bytes are written to temporary storage.
91
92 Args:
93 cert (Union[str, bytes, None]): Certificate path, raw PEM content bytes, or None.
94 key (Union[str, bytes, None]): Private key path, raw PEM content bytes, or None.
95 passphrase (Optional[bytes]): Optional passphrase for the private key.
96
97 Yields:
98 Tuple[str, str, Optional[bytes]]: The certificate path, key path, and
99 the passphrase needed to load the key (either the user's original,
100 or the newly generated one if Tier 3 had to encrypt the key).
101
102 Raises:
103 OSError: If temporary file creation or writing fails during the Tier 3 fallback.
104 """
105 # Normalize PEM strings to bytes so they are written to temporary storage.
106 # We check for "-----BEGIN " to distinguish between file paths and PEM payloads.
107 if isinstance(cert, str) and "-----BEGIN " in cert:
108 cert = cert.encode("utf-8")
109 if isinstance(key, str) and "-----BEGIN " in key:
110 key = key.encode("utf-8")
111
112 # Tier 1: Pass-through (No-op). If the caller already provided file paths,
113 # we yield them directly to avoid any unnecessary file creation.
114 if isinstance(cert, str) and isinstance(key, str):
115 yield cert, key, passphrase
116 return
117
118 # If a value is a string path, it is passed through. If bytes, we will write
119 # it to temporary storage. None values are also passed through as-is.
120 cert_bytes = cert if isinstance(cert, bytes) else None
121 key_bytes = key if isinstance(key, bytes) else None
122
123 # Tier 2: Linux RAM-backed virtual files. If supported by the OS, we write
124 # the bytes to anonymous in-memory files using memfd_create. This yields
125 # /proc/self/fd/... paths, keeping the private key entirely in memory.
126 if sys.platform == "linux" and hasattr(os, "memfd_create"):
127 try:
128 with _memfd_cert_key_paths(cert_bytes, key_bytes) as (cert_path, key_path):
129 # Handle cases where path exists but might be restricted.
130 if (cert_path is None or os.path.exists(cert_path)) and (
131 key_path is None or os.path.exists(key_path)
132 ):
133 if _can_read(cert_path) and _can_read(key_path):
134 yield (
135 cast(str, cert_path or cert),
136 cast(str, key_path or key),
137 passphrase,
138 )
139 return
140 except _MemfdCreationError:
141 pass # Fallback to Tier 3 on failure.
142
143 # Tier 3: Fallback Encrypted Temp Files. If in-memory files are not supported
144 # (macOS/Windows), we write to disk. To protect the key, we encrypt plaintext
145 # keys on-the-fly and securely wipe the files with null bytes during cleanup.
146 with _tempfile_cert_key_paths(cert_bytes, key_bytes, passphrase) as (
147 cert_path,
148 key_path,
149 new_passphrase,
150 ):
151 yield cast(str, cert_path or cert), cast(str, key_path or key), new_passphrase
152
153
154def _encrypt_key_if_plaintext(
155 key_bytes: bytes, passphrase: Optional[bytes]
156) -> Tuple[bytes, Optional[bytes]]:
157 """Encrypts a plaintext PEM key if necessary, returning the bytes and passphrase.
158
159 If the key is already encrypted, or if parsing/encryption fails, the key is
160 returned as-is (plaintext) as a fallback. This allows the caller (underlying SSL
161 context) to attempt loading the key directly and handle any failures.
162 """
163 import secrets
164
165 import cryptography
166 from cryptography.hazmat.primitives import serialization
167
168 try:
169 pkey = serialization.load_pem_private_key(key_bytes, password=None)
170 # It's plaintext, encrypt it.
171 target_passphrase = passphrase
172 if target_passphrase is None:
173 target_passphrase = secrets.token_hex(32).encode("utf-8")
174 elif isinstance(target_passphrase, str):
175 target_passphrase = target_passphrase.encode("utf-8")
176
177 encrypted_content = pkey.private_bytes(
178 encoding=serialization.Encoding.PEM,
179 format=serialization.PrivateFormat.PKCS8,
180 encryption_algorithm=serialization.BestAvailableEncryption(
181 target_passphrase
182 ),
183 )
184 return encrypted_content, target_passphrase
185 except (ValueError, TypeError, cryptography.exceptions.UnsupportedAlgorithm):
186 # Likely already encrypted, invalid, or unsupported algorithm, return as-is.
187 return key_bytes, passphrase
188
189
190def _secure_wipe_and_remove(file_path: str):
191 """Overwrites a file with null bytes before deleting it.
192
193 This is an extra security measure to make file recovery harder. However, on modern
194 solid-state drives (SSDs), the hardware optimizes where data is written, meaning
195 the original private key bytes might still physically remain on the storage chips
196 until the drive cleans them up.
197 """
198 if not os.path.exists(file_path):
199 return
200 try:
201 size = os.path.getsize(file_path)
202 with open(file_path, "r+b") as f:
203 f.write(b"\0" * size)
204 f.flush()
205 os.fsync(f.fileno())
206 except OSError:
207 pass # Ignore permission/lock errors during cleanup.
208 finally:
209 try:
210 os.remove(file_path)
211 except OSError:
212 pass
213
214
215@contextlib.contextmanager
216def _memfd_cert_key_paths(
217 cert_bytes: Optional[bytes], key_bytes: Optional[bytes]
218) -> Generator[Tuple[Optional[str], Optional[str]], None, None]:
219 """Creates secure, in-memory virtual files on Linux using memfd_create.
220
221 Yields:
222 Tuple[Optional[str], Optional[str]]: In-memory file paths pointing to
223 the active descriptors (e.g., '/proc/self/fd/3').
224 """
225 cleanup_fds = []
226 paths: List[Optional[str]] = []
227
228 try:
229 try:
230 for data, name in [(cert_bytes, "mtls_cert"), (key_bytes, "mtls_key")]:
231 if data is not None:
232 # MFD_CLOEXEC prevents FD leaks to spawned subprocesses.
233 fd = os.memfd_create(name, os.MFD_CLOEXEC) # type: ignore[attr-defined]
234 cleanup_fds.append(fd)
235 with os.fdopen(fd, "wb", closefd=False) as f:
236 f.write(data)
237 paths.append(f"/proc/self/fd/{fd}")
238 else:
239 paths.append(None)
240 except (OSError, AttributeError) as exc:
241 raise _MemfdCreationError(
242 "Failed to create in-memory virtual files"
243 ) from exc
244
245 cert_path, key_path = paths
246 yield cert_path, key_path
247 finally:
248 # Closing the descriptors automatically frees the RAM allocation.
249 for fd in cleanup_fds:
250 try:
251 os.close(fd)
252 except OSError:
253 pass
254
255
256def _write_secure_tempfile(fd: int, data: bytes) -> None:
257 """Writes data to a file descriptor, securely flushes to disk, and closes it."""
258 try:
259 f = os.fdopen(fd, "wb")
260 except BaseException:
261 try:
262 os.close(fd)
263 except OSError:
264 pass
265 raise
266
267 with f:
268 f.write(data)
269 f.flush()
270 try:
271 os.fsync(f.fileno())
272 except OSError:
273 pass
274
275
276@contextlib.contextmanager
277def _tempfile_cert_key_paths(
278 cert_bytes: Optional[bytes],
279 key_bytes: Optional[bytes],
280 passphrase: Optional[bytes],
281) -> Generator[Tuple[Optional[str], Optional[str], Optional[bytes]], None, None]:
282 """Creates secure temporary file paths on disk, encrypting private keys.
283
284 Yields:
285 Tuple[Optional[str], Optional[str], Optional[bytes]]: The temporary file
286 paths and the passphrase needed to load the key.
287 """
288 # Prioritize RAM-backed /dev/shm to avoid writing secrets to physical storage.
289 tmp_dir = (
290 "/dev/shm"
291 if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK)
292 else None
293 )
294 cleanup_files: List[Optional[str]] = [None, None]
295 new_passphrase = passphrase
296 cert_data = cert_bytes
297 key_data = None
298 if key_bytes is not None:
299 key_data, new_passphrase = _encrypt_key_if_plaintext(key_bytes, passphrase)
300
301 try:
302 for i, data in enumerate([cert_data, key_data]):
303 if data is not None:
304 try:
305 fd, path = tempfile.mkstemp(dir=tmp_dir)
306 except OSError:
307 fd, path = tempfile.mkstemp(dir=None)
308
309 cleanup_files[i] = path
310 _write_secure_tempfile(fd, data)
311
312 yield cleanup_files[0], cleanup_files[1], new_passphrase
313 finally:
314 cert_cleanup_path = cleanup_files[0]
315 key_cleanup_path = cleanup_files[1]
316
317 try:
318 if key_cleanup_path:
319 _secure_wipe_and_remove(key_cleanup_path)
320 except Exception:
321 pass
322 finally:
323 if cert_cleanup_path:
324 try:
325 if os.path.exists(cert_cleanup_path):
326 os.remove(cert_cleanup_path)
327 except OSError:
328 pass
329
330
331def _check_config_path(config_path):
332 """Checks for config file path. If it exists, returns the absolute path with user expansion;
333 otherwise returns None.
334
335 Args:
336 config_path (str): The config file path for either context_aware_metadata.json or certificate_config.json for example
337
338 Returns:
339 str: absolute path if exists and None otherwise.
340 """
341 config_path = path.expanduser(config_path)
342 if not path.exists(config_path):
343 _LOGGER.debug("%s is not found.", config_path)
344 return None
345 return config_path
346
347
348def _load_json_file(path):
349 """Reads and loads JSON from the given path. Used to read both X509 workload certificate and
350 secure connect configurations.
351
352 Args:
353 path (str): the path to read from.
354
355 Returns:
356 Dict[str, str]: The JSON stored at the file.
357
358 Raises:
359 google.auth.exceptions.ClientCertError: If failed to parse the file as JSON.
360 """
361 try:
362 with open(path) as f:
363 json_data = json.load(f)
364 except ValueError as caught_exc:
365 new_exc = exceptions.ClientCertError(caught_exc)
366 raise new_exc from caught_exc
367
368 return json_data
369
370
371def _has_explicit_cert_config_env():
372 """Returns True if an explicit certificate config environment variable is set."""
373 return bool(
374 environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG)
375 or environ.get(
376 environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH
377 )
378 )
379
380
381def _should_use_gke_credential_bundle(config_file_path=None):
382 """Returns True if no certificate config is present and the GKE credential bundle file is present."""
383 if config_file_path is not None or _has_explicit_cert_config_env():
384 return False
385 try:
386 return _agent_identity_utils._is_certificate_file_ready(
387 _GKE_CREDENTIAL_BUNDLE_PATH
388 )
389 except PermissionError:
390 return False
391
392
393def _get_workload_cert_and_key(
394 certificate_config_path=None, include_context_aware=True
395):
396 """Read the workload identity cert and key files specified in the certificate config provided.
397 If no config path is provided, check the environment variable: "GOOGLE_API_CERTIFICATE_CONFIG"
398 first, then the well known gcloud location: "~/.config/gcloud/certificate_config.json",
399 and finally fall back to the GKE workload credential bundle at
400 "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem"
401 when no explicit or implicit certificate_config.json is present.
402
403 Args:
404 certificate_config_path (string): The certificate config path. If no path is provided,
405 the environment variable will be checked first, then the well known gcloud location.
406 include_context_aware (bool): If context aware metadata path should be checked for the
407 SecureConnect mTLS configuration.
408
409 Returns:
410 Tuple[Optional[bytes], Optional[bytes]]: client certificate bytes in PEM format and key
411 bytes in PEM format.
412
413 Raises:
414 google.auth.exceptions.ClientCertError: if problems occurs when retrieving
415 the certificate or key information.
416 """
417
418 cert_path, key_path, config_file_path = _get_workload_cert_and_key_paths(
419 certificate_config_path, include_context_aware
420 )
421
422 if cert_path is None and key_path is None:
423 if certificate_config_path is None and _should_use_gke_credential_bundle(
424 config_file_path
425 ):
426 return _read_credential_bundle_file(_GKE_CREDENTIAL_BUNDLE_PATH)
427 return None, None
428
429 return _read_cert_and_key_files(cert_path, key_path)
430
431
432def _get_cert_config_path(certificate_config_path=None, include_context_aware=True):
433 """Get the certificate configuration path based on the following order:
434
435 1: Explicit override, if set
436 2: Environment variable, if set
437 3: Well-known location
438
439 Returns "None" if the selected config file does not exist.
440
441 Args:
442 certificate_config_path (string): The certificate config path. If provided, the well known
443 location and environment variable will be ignored.
444 include_context_aware (bool): If context aware metadata path should be checked for the
445 SecureConnect mTLS configuration.
446
447 Returns:
448 The absolute path of the certificate config file, and None if the file does not exist.
449 """
450
451 source = "function argument"
452 is_explicit = True
453 if certificate_config_path is None:
454 env_path = environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, None)
455 if env_path is not None and env_path != "":
456 certificate_config_path = env_path
457 source = (
458 f"environment variable {environment_vars.GOOGLE_API_CERTIFICATE_CONFIG}"
459 )
460 else:
461 env_path = environ.get(
462 environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH,
463 None,
464 )
465 if include_context_aware and env_path is not None and env_path != "":
466 certificate_config_path = env_path
467 source = f"environment variable {environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH}"
468 else:
469 certificate_config_path = os.path.join(
470 _cloud_sdk.get_config_path(), "certificate_config.json"
471 )
472 is_explicit = False
473
474 certificate_config_path = path.expanduser(certificate_config_path)
475 if not path.exists(certificate_config_path):
476 if is_explicit:
477 _LOGGER.debug(
478 "Certificate configuration file explicitly specified via %s at %s does not exist",
479 source,
480 certificate_config_path,
481 )
482 return None
483 return certificate_config_path
484
485
486def _get_workload_cert_and_key_paths(config_path=None, include_context_aware=True):
487 absolute_path = _get_cert_config_path(config_path, include_context_aware)
488 if absolute_path is None:
489 return None, None, None
490
491 data = _load_json_file(absolute_path)
492
493 if (
494 not isinstance(data, dict)
495 or "cert_configs" not in data
496 or not isinstance(data["cert_configs"], dict)
497 ):
498 raise exceptions.ClientCertError(
499 'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format(
500 absolute_path
501 )
502 )
503 cert_configs = data["cert_configs"]
504
505 # We return None, None if the expected workload fields are not present.
506 # The certificate config might be present for other types of connections (e.g. gECC),
507 # and we want to gracefully fallback to testing other mTLS configurations
508 # like SecureConnect instead of throwing an exception.
509
510 if (
511 not isinstance(cert_configs, dict) or "workload" not in cert_configs
512 ) and config_path is None:
513 default_home_path = path.expanduser(
514 os.path.join(
515 _cloud_sdk.get_config_path(),
516 "certificate_config.json",
517 )
518 )
519 if path.exists(default_home_path) and os.path.normpath(
520 default_home_path
521 ) != os.path.normpath(absolute_path):
522 try:
523 home_data = _load_json_file(default_home_path)
524 if isinstance(home_data, dict):
525 home_cert_configs = home_data.get("cert_configs")
526 if (
527 isinstance(home_cert_configs, dict)
528 and "workload" in home_cert_configs
529 ):
530 cert_configs = home_cert_configs
531 absolute_path = default_home_path
532 except (exceptions.ClientCertError, OSError):
533 pass
534
535 if not isinstance(cert_configs, dict) or "workload" not in cert_configs:
536 return None, None, absolute_path
537 workload = cert_configs["workload"]
538
539 if (
540 not isinstance(workload, dict)
541 or not isinstance(workload.get("cert_path"), str)
542 or not workload.get("cert_path")
543 or not isinstance(workload.get("key_path"), str)
544 or not workload.get("key_path")
545 ):
546 raise exceptions.ClientCertError(
547 'Workload certificate configuration is missing "cert_path" or "key_path" in {}'.format(
548 absolute_path
549 )
550 )
551 cert_path = workload["cert_path"]
552 key_path = workload["key_path"]
553
554 return cert_path, key_path, absolute_path
555
556
557def _read_credential_bundle_file(bundle_path):
558 """Reads a combined PEM credential bundle containing certificate(s) and a private key."""
559 # Read the bundle once so a certificate rotation on disk cannot pair an
560 # old certificate chain with a new private key.
561 with open(bundle_path, "rb") as bundle_file:
562 bundle_data = bundle_file.read()
563
564 return (
565 _extract_cert_chain(bundle_data, bundle_path),
566 _extract_private_key(bundle_data, bundle_path),
567 )
568
569
570def _read_cert_and_key_files(cert_path, key_path):
571 cert_data = _read_cert_file(cert_path)
572 key_data = _read_key_file(key_path)
573
574 return cert_data, key_data
575
576
577def _join_cert_chain(cert_match):
578 return b"".join(
579 m if m.endswith(b"\n") or i == len(cert_match) - 1 else m + b"\n"
580 for i, m in enumerate(cert_match)
581 )
582
583
584def _has_unmatched_pem_markers(pem_bytes, cert_blocks):
585 """Checks that every BEGIN/END CERTIFICATE marker belongs to a complete cert block."""
586 return len(cert_blocks) != pem_bytes.count(b"-----BEGIN CERTIFICATE-----") or len(
587 cert_blocks
588 ) != pem_bytes.count(b"-----END CERTIFICATE-----")
589
590
591def _extract_cert_chain(cert_data, cert_path):
592 cert_match = re.findall(_CERT_REGEX, cert_data)
593 if not cert_match or _has_unmatched_pem_markers(cert_data, cert_match):
594 raise exceptions.ClientCertError(
595 "Certificate file {} is in an invalid format, at least one PEM formatted certificate is expected".format(
596 cert_path
597 )
598 )
599 return _join_cert_chain(cert_match)
600
601
602def _extract_private_key(key_data, key_path):
603 key_match = re.findall(_KEY_REGEX, key_data)
604 if len(key_match) != 1:
605 raise exceptions.ClientCertError(
606 "Private key file {} is in an invalid format, a single PEM formatted private key is expected".format(
607 key_path
608 )
609 )
610
611 return key_match[0]
612
613
614def _read_cert_file(cert_path):
615 with open(cert_path, "rb") as cert_file:
616 cert_data = cert_file.read()
617
618 return _extract_cert_chain(cert_data, cert_path)
619
620
621def _read_key_file(key_path):
622 with open(key_path, "rb") as key_file:
623 key_data = key_file.read()
624
625 return _extract_private_key(key_data, key_path)
626
627
628def _run_cert_provider_command(command, expect_encrypted_key=False):
629 """Run the provided command, and return client side mTLS cert, key and
630 passphrase.
631
632 Args:
633 command (List[str]): cert provider command.
634 expect_encrypted_key (bool): If encrypted private key is expected.
635
636 Returns:
637 Tuple[bytes, bytes, bytes]: client certificate bytes in PEM format, key
638 bytes in PEM format and passphrase bytes.
639
640 Raises:
641 google.auth.exceptions.ClientCertError: if problems occurs when running
642 the cert provider command or generating cert, key and passphrase.
643 """
644 try:
645 process = subprocess.Popen(
646 command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
647 )
648 stdout, stderr = process.communicate()
649 except OSError as caught_exc:
650 new_exc = exceptions.ClientCertError(caught_exc)
651 raise new_exc from caught_exc
652
653 # Check cert provider command execution error.
654 if process.returncode != 0:
655 raise exceptions.ClientCertError(
656 "Cert provider command returns non-zero status code %s" % process.returncode
657 )
658
659 # Extract certificate (chain), key and passphrase.
660 cert_match = re.findall(_CERT_REGEX, stdout)
661 if not cert_match or _has_unmatched_pem_markers(stdout, cert_match):
662 raise exceptions.ClientCertError("Client SSL certificate is missing or invalid")
663 cert_chain = _join_cert_chain(cert_match)
664 key_match = re.findall(_KEY_REGEX, stdout)
665 if len(key_match) != 1:
666 raise exceptions.ClientCertError("Client SSL key is missing or invalid")
667 passphrase_match = re.findall(_PASSPHRASE_REGEX, stdout)
668
669 if expect_encrypted_key:
670 if len(passphrase_match) != 1:
671 raise exceptions.ClientCertError("Passphrase is missing or invalid")
672 if b"ENCRYPTED" not in key_match[0]:
673 raise exceptions.ClientCertError("Encrypted private key is expected")
674 return cert_chain, key_match[0], passphrase_match[0].strip()
675
676 if b"ENCRYPTED" in key_match[0]:
677 raise exceptions.ClientCertError("Encrypted private key is not expected")
678 if len(passphrase_match) > 0:
679 raise exceptions.ClientCertError("Passphrase is not expected")
680 return cert_chain, key_match[0], None
681
682
683def get_client_ssl_credentials(
684 generate_encrypted_key=False,
685 context_aware_metadata_path=CONTEXT_AWARE_METADATA_PATH,
686 certificate_config_path=None,
687):
688 """Returns the client side certificate, private key and passphrase.
689
690 We look for certificates and keys with the following order of priority:
691 1. Certificate and key specified by certificate_config.json.
692 Currently, only X.509 workload certificates are supported.
693 2. Certificate and key specified by context aware metadata (i.e. SecureConnect).
694
695 Args:
696 generate_encrypted_key (bool): If set to True, encrypted private key
697 and passphrase will be generated; otherwise, unencrypted private key
698 will be generated and passphrase will be None. This option only
699 affects keys obtained via context_aware_metadata.json.
700 context_aware_metadata_path (str): The context_aware_metadata.json file path.
701 certificate_config_path (str): The certificate_config.json file path.
702
703 Returns:
704 Tuple[bool, bytes, bytes, bytes]:
705 A boolean indicating if cert, key and passphrase are obtained, the
706 cert bytes and key bytes both in PEM format, and passphrase bytes.
707
708 Raises:
709 google.auth.exceptions.ClientCertError: if problems occurs when getting
710 the cert, key and passphrase.
711 """
712
713 # 1. Attempt to retrieve X.509 Workload cert and key.
714 cert, key = _get_workload_cert_and_key(certificate_config_path)
715 if cert and key:
716 return True, cert, key, None
717
718 # 2. Check for context aware metadata json
719 metadata_path = _check_config_path(context_aware_metadata_path)
720
721 if metadata_path:
722 metadata_json = _load_json_file(metadata_path)
723
724 if _CERT_PROVIDER_COMMAND not in metadata_json:
725 raise exceptions.ClientCertError("Cert provider command is not found")
726
727 command = metadata_json[_CERT_PROVIDER_COMMAND]
728
729 if generate_encrypted_key and "--with_passphrase" not in command:
730 command.append("--with_passphrase")
731
732 # Execute the command.
733 cert, key, passphrase = _run_cert_provider_command(
734 command, expect_encrypted_key=generate_encrypted_key
735 )
736 return True, cert, key, passphrase
737
738 return False, None, None, None
739
740
741def get_client_cert_and_key(client_cert_callback=None):
742 """Returns the client side certificate and private key. The function first
743 tries to get certificate and key from client_cert_callback; if the callback
744 is None or doesn't provide certificate and key, the function tries application
745 default SSL credentials.
746
747 Args:
748 client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An
749 optional callback which returns client certificate bytes and private
750 key bytes both in PEM format.
751
752 Returns:
753 Tuple[bool, bytes, bytes]:
754 A boolean indicating if cert and key are obtained, the cert bytes
755 and key bytes both in PEM format.
756
757 Raises:
758 google.auth.exceptions.ClientCertError: if problems occurs when getting
759 the cert and key.
760 """
761 if client_cert_callback:
762 cert, key = client_cert_callback()
763 return True, cert, key
764
765 has_cert, cert, key, _ = get_client_ssl_credentials(generate_encrypted_key=False)
766 return has_cert, cert, key
767
768
769def decrypt_private_key(key, passphrase):
770 """A helper function to decrypt the private key with the given passphrase.
771 google-auth library doesn't support passphrase protected private key for
772 mutual TLS channel. This helper function can be used to decrypt the
773 passphrase protected private key in order to estalish mutual TLS channel.
774
775 For example, if you have a function which produces client cert, passphrase
776 protected private key and passphrase, you can convert it to a client cert
777 callback function accepted by google-auth::
778
779 from google.auth.transport import _mtls_helper
780
781 def your_client_cert_function():
782 return cert, encrypted_key, passphrase
783
784 # callback accepted by google-auth for mutual TLS channel.
785 def client_cert_callback():
786 cert, encrypted_key, passphrase = your_client_cert_function()
787 decrypted_key = _mtls_helper.decrypt_private_key(encrypted_key,
788 passphrase)
789 return cert, decrypted_key
790
791 Args:
792 key (bytes): The private key bytes in PEM format.
793 passphrase (bytes): The passphrase bytes.
794
795 Returns:
796 bytes: The decrypted private key in PEM format.
797
798 Raises:
799 ValueError: If there is any problem decrypting the private key.
800 """
801 if isinstance(key, str):
802 key = key.encode("utf-8")
803 if isinstance(passphrase, str):
804 passphrase = passphrase.encode("utf-8")
805
806 from cryptography.hazmat.primitives import serialization
807
808 # First convert encrypted_key_bytes to PKey object
809 pkey = serialization.load_pem_private_key(key, password=passphrase)
810
811 # Then dump the decrypted key bytes
812 return pkey.private_bytes(
813 encoding=serialization.Encoding.PEM,
814 format=serialization.PrivateFormat.PKCS8,
815 encryption_algorithm=serialization.NoEncryption(),
816 )
817
818
819def _check_use_client_cert_env():
820 use_client_cert = getenv(
821 environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE
822 ) or getenv(environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE)
823
824 if use_client_cert:
825 return use_client_cert.lower() == "true"
826 return None
827
828
829def check_use_client_cert():
830 """Returns boolean for whether the client certificate should be used for mTLS.
831
832 If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding
833 bool value will be returned. If the value is set to an unexpected string, it
834 will default to False.
835 If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred
836 as True (auto-enabled) if a workload config file exists (pointed at by
837 GOOGLE_API_CERTIFICATE_CONFIG or CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH,
838 or the default path like ~/.config/gcloud/certificate_config.json)
839 containing a "workload" section, or if no certificate config file is
840 configured/present and the GKE credential bundle exists at
841 /var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem.
842 Otherwise, it returns False.
843
844 Returns:
845 bool: Whether the client certificate should be used for mTLS connection.
846 """
847 env_override = _check_use_client_cert_env()
848 if env_override is not None:
849 return env_override
850
851 # Auto-enablement checks (when GOOGLE_API_USE_CLIENT_CERTIFICATE is not set)
852
853 # Check if a workload config file exists.
854 cert_path = _get_cert_config_path(include_context_aware=True)
855
856 if cert_path:
857 try:
858 with open(cert_path, "r") as f:
859 content = json.load(f)
860 except (FileNotFoundError, OSError, json.JSONDecodeError) as e:
861 _LOGGER.debug(
862 "mTLS auto-enablement failed: Could not read/parse certificate file at %s. Error: %s",
863 cert_path,
864 e,
865 )
866 return False
867
868 # Structural validation
869 if isinstance(content, dict):
870 cert_configs = content.get("cert_configs")
871 if isinstance(cert_configs, dict) and "workload" in cert_configs:
872 return True
873
874 # If we got here, the file exists but the expected structure is missing
875 _LOGGER.debug(
876 "mTLS auto-enablement failed: Certificate configuration file at %s is missing the required ['cert_configs']['workload'] section.",
877 cert_path,
878 )
879 return False
880
881 return _should_use_gke_credential_bundle(cert_path)
882
883
884def check_parameters_for_unauthorized_response(cached_cert):
885 """Returns the cached and current cert fingerprint for reconfiguring mTLS.
886
887 Args:
888 cached_cert (Optional[bytes]): The cached client certificate.
889
890 Returns:
891 Tuple[Optional[bytes], Optional[bytes], Optional[str], Optional[str]]:
892 The client callback cert bytes, client callback key bytes,
893 base64-encoded SHA256 cached fingerprint, and base64-encoded SHA256
894 current cert fingerprint.
895 """
896 call_cert_bytes, call_key_bytes = call_client_cert_callback()
897 if not call_cert_bytes:
898 return None, None, None, None
899 cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes)
900 current_cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(
901 cert_obj
902 )
903 if cached_cert:
904 cached_fingerprint = _agent_identity_utils.get_cached_cert_fingerprint(
905 cached_cert
906 )
907 else:
908 cached_fingerprint = current_cert_fingerprint
909 return call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint
910
911
912def call_client_cert_callback():
913 """Calls the client cert callback and returns the certificate and key."""
914 _, cert_bytes, key_bytes, passphrase = get_client_ssl_credentials(
915 generate_encrypted_key=True
916 )
917 return cert_bytes, key_bytes
918
919
920_MTLS_HOST_SUFFIXES = (
921 ".mtls.googleapis.com",
922 ".mtls.sandbox.googleapis.com",
923 ".p.googleapis.com",
924 ".mtls.run.app",
925)
926_MTLS_EXACT_HOSTS = (
927 "mtls.googleapis.com",
928 "mtls.sandbox.googleapis.com",
929 "p.googleapis.com",
930 "mtls.run.app",
931)
932
933
934def is_mtls_endpoint(url: Optional[Union[str, bytes, object]]) -> bool:
935 """Checks if the given URL corresponds to an mTLS (Google APIs or Cloud Run)
936 or Private Service Connect (PSC) endpoint.
937
938 Args:
939 url (Optional[Union[str, bytes, object]]): The request URL.
940
941 Returns:
942 bool: True if the URL targets an mTLS or PSC endpoint, False otherwise.
943 """
944 if not url:
945 return False
946 if hasattr(url, "url") and isinstance(url.url, (str, bytes)):
947 url = url.url
948 if isinstance(url, bytes):
949 try:
950 url = url.decode("utf-8")
951 except (UnicodeDecodeError, AttributeError):
952 return False
953 elif not isinstance(url, str):
954 url = str(url)
955 try:
956 hostname = urlsplit(url).hostname
957 except (ValueError, TypeError, AttributeError):
958 return False
959
960 if not hostname:
961 return False
962
963 hostname = hostname.rstrip(".").lower()
964 if not hostname:
965 return False
966
967 return hostname in _MTLS_EXACT_HOSTS or hostname.endswith(_MTLS_HOST_SUFFIXES)