Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/google/auth/transport/_mtls_helper.py: 21%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

357 statements  

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 

21from os import environ, getenv, path 

22import re 

23import subprocess 

24import sys 

25import tempfile 

26from typing import cast, Generator, List, Optional, Tuple, Union 

27from urllib.parse import urlsplit 

28 

29from google.auth import _agent_identity_utils 

30from google.auth import _cloud_sdk 

31from google.auth import environment_vars 

32from google.auth import exceptions 

33 

34CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json" 

35 

36# Default gcloud config path, to be used with path.expanduser for cross-platform compatibility. 

37CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json" 

38_CERT_PROVIDER_COMMAND = "cert_provider_command" 

39_CERT_REGEX = re.compile( 

40 b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL 

41) 

42 

43# support various format of key files, e.g. 

44# "-----BEGIN PRIVATE KEY-----...", 

45# "-----BEGIN EC PRIVATE KEY-----...", 

46# "-----BEGIN RSA PRIVATE KEY-----..." 

47# "-----BEGIN ENCRYPTED PRIVATE KEY-----" 

48_KEY_REGEX = re.compile( 

49 b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+-----END [A-Z ]*PRIVATE KEY-----\r?\n?", 

50 re.DOTALL, 

51) 

52 

53_LOGGER = logging.getLogger(__name__) 

54 

55 

56_PASSPHRASE_REGEX = re.compile( 

57 b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL 

58) 

59 

60 

61class _MemfdCreationError(OSError): 

62 """Raised when Linux in-memory virtual file creation (memfd) fails.""" 

63 

64 pass 

65 

66 

67def _can_read(path: Optional[str]) -> bool: 

68 if path is None: 

69 return True 

70 try: 

71 with open(path, "rb"): 

72 pass 

73 return True 

74 except OSError: 

75 return False 

76 

77 

78@contextlib.contextmanager 

79def secure_cert_key_paths( 

80 cert: Union[bytes, str, None], 

81 key: Union[bytes, str, None], 

82 passphrase: Optional[bytes] = None, 

83) -> Generator[Tuple[Optional[str], Optional[str], Optional[bytes]], None, None]: 

84 """Provides secure file paths for certificate and key. 

85 

86 This function is implemented as a context manager generator to ensure that 

87 any temporary resources (such as in-memory virtual files or encrypted physical 

88 temp files) are automatically cleaned up and securely wiped when the context exits. 

89 

90 It supports mixed inputs (e.g. passing one as a string path and the other as bytes). 

91 If a parameter is already a string path or None, it is passed through as-is, and 

92 only raw bytes are written to temporary storage. 

93 

94 Args: 

95 cert (Union[str, bytes, None]): Certificate path, raw PEM content bytes, or None. 

96 key (Union[str, bytes, None]): Private key path, raw PEM content bytes, or None. 

97 passphrase (Optional[bytes]): Optional passphrase for the private key. 

98 

99 Yields: 

100 Tuple[str, str, Optional[bytes]]: The certificate path, key path, and 

101 the passphrase needed to load the key (either the user's original, 

102 or the newly generated one if Tier 3 had to encrypt the key). 

103 

104 Raises: 

105 OSError: If temporary file creation or writing fails during the Tier 3 fallback. 

106 """ 

107 # Normalize PEM strings to bytes so they are written to temporary storage. 

108 # We check for "-----BEGIN " to distinguish between file paths and PEM payloads. 

109 if isinstance(cert, str) and "-----BEGIN " in cert: 

110 cert = cert.encode("utf-8") 

111 if isinstance(key, str) and "-----BEGIN " in key: 

112 key = key.encode("utf-8") 

113 

114 # Tier 1: Pass-through (No-op). If the caller already provided file paths, 

115 # we yield them directly to avoid any unnecessary file creation. 

116 if isinstance(cert, str) and isinstance(key, str): 

117 yield cert, key, passphrase 

118 return 

119 

120 # If a value is a string path, it is passed through. If bytes, we will write 

121 # it to temporary storage. None values are also passed through as-is. 

122 cert_bytes = cert if isinstance(cert, bytes) else None 

123 key_bytes = key if isinstance(key, bytes) else None 

124 

125 # Tier 2: Linux RAM-backed virtual files. If supported by the OS, we write 

126 # the bytes to anonymous in-memory files using memfd_create. This yields 

127 # /proc/self/fd/... paths, keeping the private key entirely in memory. 

128 if sys.platform == "linux" and hasattr(os, "memfd_create"): 

129 try: 

130 with _memfd_cert_key_paths(cert_bytes, key_bytes) as (cert_path, key_path): 

131 # Handle cases where path exists but might be restricted. 

132 if (cert_path is None or os.path.exists(cert_path)) and ( 

133 key_path is None or os.path.exists(key_path) 

134 ): 

135 if _can_read(cert_path) and _can_read(key_path): 

136 yield cast(str, cert_path or cert), cast( 

137 str, key_path or key 

138 ), passphrase 

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 cryptography 

164 from cryptography.hazmat.primitives import serialization 

165 import secrets 

166 

167 try: 

168 pkey = serialization.load_pem_private_key(key_bytes, password=None) 

169 # It's plaintext, encrypt it. 

170 target_passphrase = passphrase 

171 if target_passphrase is None: 

172 target_passphrase = secrets.token_hex(32).encode("utf-8") 

173 elif isinstance(target_passphrase, str): 

174 target_passphrase = target_passphrase.encode("utf-8") 

175 

176 encrypted_content = pkey.private_bytes( 

177 encoding=serialization.Encoding.PEM, 

178 format=serialization.PrivateFormat.PKCS8, 

179 encryption_algorithm=serialization.BestAvailableEncryption( 

180 target_passphrase 

181 ), 

182 ) 

183 return encrypted_content, target_passphrase 

184 except (ValueError, TypeError, cryptography.exceptions.UnsupportedAlgorithm): 

185 # Likely already encrypted, invalid, or unsupported algorithm, return as-is. 

186 return key_bytes, passphrase 

187 

188 

189def _secure_wipe_and_remove(file_path: str): 

190 """Overwrites a file with null bytes before deleting it. 

191 

192 This is an extra security measure to make file recovery harder. However, on modern 

193 solid-state drives (SSDs), the hardware optimizes where data is written, meaning 

194 the original private key bytes might still physically remain on the storage chips 

195 until the drive cleans them up. 

196 """ 

197 if not os.path.exists(file_path): 

198 return 

199 try: 

200 size = os.path.getsize(file_path) 

201 with open(file_path, "r+b") as f: 

202 f.write(b"\0" * size) 

203 f.flush() 

204 os.fsync(f.fileno()) 

205 except OSError: 

206 pass # Ignore permission/lock errors during cleanup. 

207 finally: 

208 try: 

209 os.remove(file_path) 

210 except OSError: 

211 pass 

212 

213 

214@contextlib.contextmanager 

215def _memfd_cert_key_paths( 

216 cert_bytes: Optional[bytes], key_bytes: Optional[bytes] 

217) -> Generator[Tuple[Optional[str], Optional[str]], None, None]: 

218 """Creates secure, in-memory virtual files on Linux using memfd_create. 

219 

220 Yields: 

221 Tuple[Optional[str], Optional[str]]: In-memory file paths pointing to 

222 the active descriptors (e.g., '/proc/self/fd/3'). 

223 """ 

224 cleanup_fds = [] 

225 paths: List[Optional[str]] = [] 

226 

227 try: 

228 try: 

229 for data, name in [(cert_bytes, "mtls_cert"), (key_bytes, "mtls_key")]: 

230 if data is not None: 

231 # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. 

232 fd = os.memfd_create(name, os.MFD_CLOEXEC) # type: ignore[attr-defined] 

233 cleanup_fds.append(fd) 

234 with os.fdopen(fd, "wb", closefd=False) as f: 

235 f.write(data) 

236 paths.append(f"/proc/self/fd/{fd}") 

237 else: 

238 paths.append(None) 

239 except (OSError, AttributeError) as exc: 

240 raise _MemfdCreationError( 

241 "Failed to create in-memory virtual files" 

242 ) from exc 

243 

244 cert_path, key_path = paths 

245 yield cert_path, key_path 

246 finally: 

247 # Closing the descriptors automatically frees the RAM allocation. 

248 for fd in cleanup_fds: 

249 try: 

250 os.close(fd) 

251 except OSError: 

252 pass 

253 

254 

255def _write_secure_tempfile(fd: int, data: bytes) -> None: 

256 """Writes data to a file descriptor, securely flushes to disk, and closes it.""" 

257 try: 

258 f = os.fdopen(fd, "wb") 

259 except BaseException: 

260 try: 

261 os.close(fd) 

262 except OSError: 

263 pass 

264 raise 

265 

266 with f: 

267 f.write(data) 

268 f.flush() 

269 try: 

270 os.fsync(f.fileno()) 

271 except OSError: 

272 pass 

273 

274 

275@contextlib.contextmanager 

276def _tempfile_cert_key_paths( 

277 cert_bytes: Optional[bytes], 

278 key_bytes: Optional[bytes], 

279 passphrase: Optional[bytes], 

280) -> Generator[Tuple[Optional[str], Optional[str], Optional[bytes]], None, None]: 

281 """Creates secure temporary file paths on disk, encrypting private keys. 

282 

283 Yields: 

284 Tuple[Optional[str], Optional[str], Optional[bytes]]: The temporary file 

285 paths and the passphrase needed to load the key. 

286 """ 

287 # Prioritize RAM-backed /dev/shm to avoid writing secrets to physical storage. 

288 tmp_dir = ( 

289 "/dev/shm" 

290 if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) 

291 else None 

292 ) 

293 cleanup_files: List[Optional[str]] = [None, None] 

294 new_passphrase = passphrase 

295 cert_data = cert_bytes 

296 key_data = None 

297 if key_bytes is not None: 

298 key_data, new_passphrase = _encrypt_key_if_plaintext(key_bytes, passphrase) 

299 

300 try: 

301 for i, data in enumerate([cert_data, key_data]): 

302 if data is not None: 

303 try: 

304 fd, path = tempfile.mkstemp(dir=tmp_dir) 

305 except OSError: 

306 fd, path = tempfile.mkstemp(dir=None) 

307 

308 cleanup_files[i] = path 

309 _write_secure_tempfile(fd, data) 

310 

311 yield cleanup_files[0], cleanup_files[1], new_passphrase 

312 finally: 

313 cert_cleanup_path = cleanup_files[0] 

314 key_cleanup_path = cleanup_files[1] 

315 

316 try: 

317 if key_cleanup_path: 

318 _secure_wipe_and_remove(key_cleanup_path) 

319 except Exception: 

320 pass 

321 finally: 

322 if cert_cleanup_path: 

323 try: 

324 if os.path.exists(cert_cleanup_path): 

325 os.remove(cert_cleanup_path) 

326 except OSError: 

327 pass 

328 

329 

330def _check_config_path(config_path): 

331 """Checks for config file path. If it exists, returns the absolute path with user expansion; 

332 otherwise returns None. 

333 

334 Args: 

335 config_path (str): The config file path for either context_aware_metadata.json or certificate_config.json for example 

336 

337 Returns: 

338 str: absolute path if exists and None otherwise. 

339 """ 

340 config_path = path.expanduser(config_path) 

341 if not path.exists(config_path): 

342 _LOGGER.debug("%s is not found.", config_path) 

343 return None 

344 return config_path 

345 

346 

347def _load_json_file(path): 

348 """Reads and loads JSON from the given path. Used to read both X509 workload certificate and 

349 secure connect configurations. 

350 

351 Args: 

352 path (str): the path to read from. 

353 

354 Returns: 

355 Dict[str, str]: The JSON stored at the file. 

356 

357 Raises: 

358 google.auth.exceptions.ClientCertError: If failed to parse the file as JSON. 

359 """ 

360 try: 

361 with open(path) as f: 

362 json_data = json.load(f) 

363 except ValueError as caught_exc: 

364 new_exc = exceptions.ClientCertError(caught_exc) 

365 raise new_exc from caught_exc 

366 

367 return json_data 

368 

369 

370def _get_workload_cert_and_key( 

371 certificate_config_path=None, include_context_aware=True 

372): 

373 """Read the workload identity cert and key files specified in the certificate config provided. 

374 If no config path is provided, check the environment variable: "GOOGLE_API_CERTIFICATE_CONFIG" 

375 first, then the well known gcloud location: "~/.config/gcloud/certificate_config.json". 

376 

377 Args: 

378 certificate_config_path (string): The certificate config path. If no path is provided, 

379 the environment variable will be checked first, then the well known gcloud location. 

380 include_context_aware (bool): If context aware metadata path should be checked for the 

381 SecureConnect mTLS configuration. 

382 

383 Returns: 

384 Tuple[Optional[bytes], Optional[bytes]]: client certificate bytes in PEM format and key 

385 bytes in PEM format. 

386 

387 Raises: 

388 google.auth.exceptions.ClientCertError: if problems occurs when retrieving 

389 the certificate or key information. 

390 """ 

391 

392 cert_path, key_path = _get_workload_cert_and_key_paths( 

393 certificate_config_path, include_context_aware 

394 ) 

395 

396 if cert_path is None and key_path is None: 

397 return None, None 

398 

399 return _read_cert_and_key_files(cert_path, key_path) 

400 

401 

402def _get_cert_config_path(certificate_config_path=None, include_context_aware=True): 

403 """Get the certificate configuration path based on the following order: 

404 

405 1: Explicit override, if set 

406 2: Environment variable, if set 

407 3: Well-known location 

408 

409 Returns "None" if the selected config file does not exist. 

410 

411 Args: 

412 certificate_config_path (string): The certificate config path. If provided, the well known 

413 location and environment variable will be ignored. 

414 include_context_aware (bool): If context aware metadata path should be checked for the 

415 SecureConnect mTLS configuration. 

416 

417 Returns: 

418 The absolute path of the certificate config file, and None if the file does not exist. 

419 """ 

420 

421 source = "function argument" 

422 is_explicit = True 

423 if certificate_config_path is None: 

424 env_path = environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, None) 

425 if env_path is not None and env_path != "": 

426 certificate_config_path = env_path 

427 source = ( 

428 f"environment variable {environment_vars.GOOGLE_API_CERTIFICATE_CONFIG}" 

429 ) 

430 else: 

431 env_path = environ.get( 

432 environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH, 

433 None, 

434 ) 

435 if include_context_aware and env_path is not None and env_path != "": 

436 certificate_config_path = env_path 

437 source = f"environment variable {environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH}" 

438 else: 

439 certificate_config_path = os.path.join( 

440 _cloud_sdk.get_config_path(), "certificate_config.json" 

441 ) 

442 is_explicit = False 

443 

444 certificate_config_path = path.expanduser(certificate_config_path) 

445 if not path.exists(certificate_config_path): 

446 if is_explicit: 

447 _LOGGER.debug( 

448 "Certificate configuration file explicitly specified via %s at %s does not exist", 

449 source, 

450 certificate_config_path, 

451 ) 

452 return None 

453 return certificate_config_path 

454 

455 

456def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): 

457 absolute_path = _get_cert_config_path(config_path, include_context_aware) 

458 if absolute_path is None: 

459 return None, None 

460 

461 data = _load_json_file(absolute_path) 

462 

463 if ( 

464 not isinstance(data, dict) 

465 or "cert_configs" not in data 

466 or not isinstance(data["cert_configs"], dict) 

467 ): 

468 raise exceptions.ClientCertError( 

469 'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format( 

470 absolute_path 

471 ) 

472 ) 

473 cert_configs = data["cert_configs"] 

474 

475 # We return None, None if the expected workload fields are not present. 

476 # The certificate config might be present for other types of connections (e.g. gECC), 

477 # and we want to gracefully fallback to testing other mTLS configurations 

478 # like SecureConnect instead of throwing an exception. 

479 

480 if ( 

481 not isinstance(cert_configs, dict) or "workload" not in cert_configs 

482 ) and config_path is None: 

483 default_home_path = path.expanduser( 

484 os.path.join( 

485 _cloud_sdk.get_config_path(), 

486 "certificate_config.json", 

487 ) 

488 ) 

489 if path.exists(default_home_path) and os.path.normpath( 

490 default_home_path 

491 ) != os.path.normpath(absolute_path): 

492 try: 

493 home_data = _load_json_file(default_home_path) 

494 if isinstance(home_data, dict): 

495 home_cert_configs = home_data.get("cert_configs") 

496 if ( 

497 isinstance(home_cert_configs, dict) 

498 and "workload" in home_cert_configs 

499 ): 

500 cert_configs = home_cert_configs 

501 absolute_path = default_home_path 

502 except (exceptions.ClientCertError, OSError): 

503 pass 

504 

505 if not isinstance(cert_configs, dict) or "workload" not in cert_configs: 

506 return None, None 

507 workload = cert_configs["workload"] 

508 

509 if ( 

510 not isinstance(workload, dict) 

511 or "cert_path" not in workload 

512 or "key_path" not in workload 

513 ): 

514 raise exceptions.ClientCertError( 

515 'Workload certificate configuration is missing "cert_path" or "key_path" in {}'.format( 

516 absolute_path 

517 ) 

518 ) 

519 cert_path = workload["cert_path"] 

520 key_path = workload["key_path"] 

521 

522 return cert_path, key_path 

523 

524 

525def _read_cert_and_key_files(cert_path, key_path): 

526 cert_data = _read_cert_file(cert_path) 

527 key_data = _read_key_file(key_path) 

528 

529 return cert_data, key_data 

530 

531 

532def _read_cert_file(cert_path): 

533 with open(cert_path, "rb") as cert_file: 

534 cert_data = cert_file.read() 

535 

536 cert_match = re.findall(_CERT_REGEX, cert_data) 

537 if len(cert_match) != 1: 

538 raise exceptions.ClientCertError( 

539 "Certificate file {} is in an invalid format, a single PEM formatted certificate is expected".format( 

540 cert_path 

541 ) 

542 ) 

543 return cert_match[0] 

544 

545 

546def _read_key_file(key_path): 

547 with open(key_path, "rb") as key_file: 

548 key_data = key_file.read() 

549 

550 key_match = re.findall(_KEY_REGEX, key_data) 

551 if len(key_match) != 1: 

552 raise exceptions.ClientCertError( 

553 "Private key file {} is in an invalid format, a single PEM formatted private key is expected".format( 

554 key_path 

555 ) 

556 ) 

557 

558 return key_match[0] 

559 

560 

561def _run_cert_provider_command(command, expect_encrypted_key=False): 

562 """Run the provided command, and return client side mTLS cert, key and 

563 passphrase. 

564 

565 Args: 

566 command (List[str]): cert provider command. 

567 expect_encrypted_key (bool): If encrypted private key is expected. 

568 

569 Returns: 

570 Tuple[bytes, bytes, bytes]: client certificate bytes in PEM format, key 

571 bytes in PEM format and passphrase bytes. 

572 

573 Raises: 

574 google.auth.exceptions.ClientCertError: if problems occurs when running 

575 the cert provider command or generating cert, key and passphrase. 

576 """ 

577 try: 

578 process = subprocess.Popen( 

579 command, stdout=subprocess.PIPE, stderr=subprocess.PIPE 

580 ) 

581 stdout, stderr = process.communicate() 

582 except OSError as caught_exc: 

583 new_exc = exceptions.ClientCertError(caught_exc) 

584 raise new_exc from caught_exc 

585 

586 # Check cert provider command execution error. 

587 if process.returncode != 0: 

588 raise exceptions.ClientCertError( 

589 "Cert provider command returns non-zero status code %s" % process.returncode 

590 ) 

591 

592 # Extract certificate (chain), key and passphrase. 

593 cert_match = re.findall(_CERT_REGEX, stdout) 

594 if len(cert_match) != 1: 

595 raise exceptions.ClientCertError("Client SSL certificate is missing or invalid") 

596 key_match = re.findall(_KEY_REGEX, stdout) 

597 if len(key_match) != 1: 

598 raise exceptions.ClientCertError("Client SSL key is missing or invalid") 

599 passphrase_match = re.findall(_PASSPHRASE_REGEX, stdout) 

600 

601 if expect_encrypted_key: 

602 if len(passphrase_match) != 1: 

603 raise exceptions.ClientCertError("Passphrase is missing or invalid") 

604 if b"ENCRYPTED" not in key_match[0]: 

605 raise exceptions.ClientCertError("Encrypted private key is expected") 

606 return cert_match[0], key_match[0], passphrase_match[0].strip() 

607 

608 if b"ENCRYPTED" in key_match[0]: 

609 raise exceptions.ClientCertError("Encrypted private key is not expected") 

610 if len(passphrase_match) > 0: 

611 raise exceptions.ClientCertError("Passphrase is not expected") 

612 return cert_match[0], key_match[0], None 

613 

614 

615def get_client_ssl_credentials( 

616 generate_encrypted_key=False, 

617 context_aware_metadata_path=CONTEXT_AWARE_METADATA_PATH, 

618 certificate_config_path=None, 

619): 

620 """Returns the client side certificate, private key and passphrase. 

621 

622 We look for certificates and keys with the following order of priority: 

623 1. Certificate and key specified by certificate_config.json. 

624 Currently, only X.509 workload certificates are supported. 

625 2. Certificate and key specified by context aware metadata (i.e. SecureConnect). 

626 

627 Args: 

628 generate_encrypted_key (bool): If set to True, encrypted private key 

629 and passphrase will be generated; otherwise, unencrypted private key 

630 will be generated and passphrase will be None. This option only 

631 affects keys obtained via context_aware_metadata.json. 

632 context_aware_metadata_path (str): The context_aware_metadata.json file path. 

633 certificate_config_path (str): The certificate_config.json file path. 

634 

635 Returns: 

636 Tuple[bool, bytes, bytes, bytes]: 

637 A boolean indicating if cert, key and passphrase are obtained, the 

638 cert bytes and key bytes both in PEM format, and passphrase bytes. 

639 

640 Raises: 

641 google.auth.exceptions.ClientCertError: if problems occurs when getting 

642 the cert, key and passphrase. 

643 """ 

644 

645 # 1. Attempt to retrieve X.509 Workload cert and key. 

646 cert, key = _get_workload_cert_and_key(certificate_config_path) 

647 if cert and key: 

648 return True, cert, key, None 

649 

650 # 2. Check for context aware metadata json 

651 metadata_path = _check_config_path(context_aware_metadata_path) 

652 

653 if metadata_path: 

654 metadata_json = _load_json_file(metadata_path) 

655 

656 if _CERT_PROVIDER_COMMAND not in metadata_json: 

657 raise exceptions.ClientCertError("Cert provider command is not found") 

658 

659 command = metadata_json[_CERT_PROVIDER_COMMAND] 

660 

661 if generate_encrypted_key and "--with_passphrase" not in command: 

662 command.append("--with_passphrase") 

663 

664 # Execute the command. 

665 cert, key, passphrase = _run_cert_provider_command( 

666 command, expect_encrypted_key=generate_encrypted_key 

667 ) 

668 return True, cert, key, passphrase 

669 

670 return False, None, None, None 

671 

672 

673def get_client_cert_and_key(client_cert_callback=None): 

674 """Returns the client side certificate and private key. The function first 

675 tries to get certificate and key from client_cert_callback; if the callback 

676 is None or doesn't provide certificate and key, the function tries application 

677 default SSL credentials. 

678 

679 Args: 

680 client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An 

681 optional callback which returns client certificate bytes and private 

682 key bytes both in PEM format. 

683 

684 Returns: 

685 Tuple[bool, bytes, bytes]: 

686 A boolean indicating if cert and key are obtained, the cert bytes 

687 and key bytes both in PEM format. 

688 

689 Raises: 

690 google.auth.exceptions.ClientCertError: if problems occurs when getting 

691 the cert and key. 

692 """ 

693 if client_cert_callback: 

694 cert, key = client_cert_callback() 

695 return True, cert, key 

696 

697 has_cert, cert, key, _ = get_client_ssl_credentials(generate_encrypted_key=False) 

698 return has_cert, cert, key 

699 

700 

701def decrypt_private_key(key, passphrase): 

702 """A helper function to decrypt the private key with the given passphrase. 

703 google-auth library doesn't support passphrase protected private key for 

704 mutual TLS channel. This helper function can be used to decrypt the 

705 passphrase protected private key in order to estalish mutual TLS channel. 

706 

707 For example, if you have a function which produces client cert, passphrase 

708 protected private key and passphrase, you can convert it to a client cert 

709 callback function accepted by google-auth:: 

710 

711 from google.auth.transport import _mtls_helper 

712 

713 def your_client_cert_function(): 

714 return cert, encrypted_key, passphrase 

715 

716 # callback accepted by google-auth for mutual TLS channel. 

717 def client_cert_callback(): 

718 cert, encrypted_key, passphrase = your_client_cert_function() 

719 decrypted_key = _mtls_helper.decrypt_private_key(encrypted_key, 

720 passphrase) 

721 return cert, decrypted_key 

722 

723 Args: 

724 key (bytes): The private key bytes in PEM format. 

725 passphrase (bytes): The passphrase bytes. 

726 

727 Returns: 

728 bytes: The decrypted private key in PEM format. 

729 

730 Raises: 

731 ValueError: If there is any problem decrypting the private key. 

732 """ 

733 if isinstance(key, str): 

734 key = key.encode("utf-8") 

735 if isinstance(passphrase, str): 

736 passphrase = passphrase.encode("utf-8") 

737 

738 from cryptography.hazmat.primitives import serialization 

739 

740 # First convert encrypted_key_bytes to PKey object 

741 pkey = serialization.load_pem_private_key(key, password=passphrase) 

742 

743 # Then dump the decrypted key bytes 

744 return pkey.private_bytes( 

745 encoding=serialization.Encoding.PEM, 

746 format=serialization.PrivateFormat.PKCS8, 

747 encryption_algorithm=serialization.NoEncryption(), 

748 ) 

749 

750 

751def _check_use_client_cert_env(): 

752 use_client_cert = getenv( 

753 environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE 

754 ) or getenv(environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE) 

755 

756 if use_client_cert: 

757 return use_client_cert.lower() == "true" 

758 return None 

759 

760 

761def check_use_client_cert(): 

762 """Returns boolean for whether the client certificate should be used for mTLS. 

763 

764 If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding 

765 bool value will be returned. If the value is set to an unexpected string, it 

766 will default to False. 

767 If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred 

768 as True (auto-enabled) if a workload config file exists (pointed at by 

769 GOOGLE_API_CERTIFICATE_CONFIG or CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH, 

770 or the default path like ~/.config/gcloud/certificate_config.json) 

771 containing a "workload" section. 

772 Otherwise, it returns False. 

773 

774 Returns: 

775 bool: Whether the client certificate should be used for mTLS connection. 

776 """ 

777 env_override = _check_use_client_cert_env() 

778 if env_override is not None: 

779 return env_override 

780 

781 # Auto-enablement checks (when GOOGLE_API_USE_CLIENT_CERTIFICATE is not set) 

782 

783 # Check if a workload config file exists. 

784 cert_path = _get_cert_config_path(include_context_aware=True) 

785 

786 if cert_path: 

787 try: 

788 with open(cert_path, "r") as f: 

789 content = json.load(f) 

790 except (FileNotFoundError, OSError, json.JSONDecodeError) as e: 

791 _LOGGER.debug( 

792 "mTLS auto-enablement failed: Could not read/parse certificate file at %s. Error: %s", 

793 cert_path, 

794 e, 

795 ) 

796 return False 

797 

798 # Structural validation 

799 if isinstance(content, dict): 

800 cert_configs = content.get("cert_configs") 

801 if isinstance(cert_configs, dict) and "workload" in cert_configs: 

802 return True 

803 

804 # If we got here, the file exists but the expected structure is missing 

805 _LOGGER.debug( 

806 "mTLS auto-enablement failed: Certificate configuration file at %s is missing the required ['cert_configs']['workload'] section.", 

807 cert_path, 

808 ) 

809 return False 

810 

811 

812def check_parameters_for_unauthorized_response(cached_cert): 

813 """Returns the cached and current cert fingerprint for reconfiguring mTLS. 

814 

815 Args: 

816 cached_cert(bytes): The cached client certificate. 

817 

818 Returns: 

819 bytes: The client callback cert bytes. 

820 bytes: The client callback key bytes. 

821 str: The base64-encoded SHA256 cached fingerprint. 

822 str: The base64-encoded SHA256 current cert fingerprint. 

823 """ 

824 call_cert_bytes, call_key_bytes = call_client_cert_callback() 

825 cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes) 

826 current_cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint( 

827 cert_obj 

828 ) 

829 if cached_cert: 

830 cached_fingerprint = _agent_identity_utils.get_cached_cert_fingerprint( 

831 cached_cert 

832 ) 

833 else: 

834 cached_fingerprint = current_cert_fingerprint 

835 return call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint 

836 

837 

838def call_client_cert_callback(): 

839 """Calls the client cert callback and returns the certificate and key.""" 

840 _, cert_bytes, key_bytes, passphrase = get_client_ssl_credentials( 

841 generate_encrypted_key=True 

842 ) 

843 return cert_bytes, key_bytes 

844 

845 

846_MTLS_HOST_SUFFIXES = ( 

847 ".mtls.googleapis.com", 

848 ".mtls.sandbox.googleapis.com", 

849 ".p.googleapis.com", 

850) 

851_MTLS_EXACT_HOSTS = ( 

852 "mtls.googleapis.com", 

853 "mtls.sandbox.googleapis.com", 

854 "p.googleapis.com", 

855) 

856 

857 

858def is_mtls_endpoint(url: Optional[Union[str, bytes, object]]) -> bool: 

859 """Checks if the given URL corresponds to an mTLS or Private Service Connect (PSC) endpoint. 

860 

861 Args: 

862 url (Optional[Union[str, bytes, object]]): The request URL. 

863 

864 Returns: 

865 bool: True if the URL targets an mTLS or PSC endpoint, False otherwise. 

866 """ 

867 if not url: 

868 return False 

869 if hasattr(url, "url") and isinstance(url.url, (str, bytes)): 

870 url = url.url 

871 if isinstance(url, bytes): 

872 try: 

873 url = url.decode("utf-8") 

874 except (UnicodeDecodeError, AttributeError): 

875 return False 

876 elif not isinstance(url, str): 

877 url = str(url) 

878 try: 

879 hostname = urlsplit(url).hostname 

880 except (ValueError, TypeError, AttributeError): 

881 return False 

882 

883 if not hostname: 

884 return False 

885 

886 hostname = hostname.rstrip(".").lower() 

887 if not hostname: 

888 return False 

889 

890 return hostname in _MTLS_EXACT_HOSTS or hostname.endswith(_MTLS_HOST_SUFFIXES)