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

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

320 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 

27 

28from google.auth import _agent_identity_utils 

29from google.auth import _cloud_sdk 

30from google.auth import environment_vars 

31from google.auth import exceptions 

32 

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

34 

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

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

37_CERT_PROVIDER_COMMAND = "cert_provider_command" 

38_CERT_REGEX = re.compile( 

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

40) 

41 

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

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

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

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

46# "-----BEGIN ENCRYPTED PRIVATE KEY-----" 

47_KEY_REGEX = re.compile( 

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

49 re.DOTALL, 

50) 

51 

52_LOGGER = logging.getLogger(__name__) 

53 

54 

55_PASSPHRASE_REGEX = re.compile( 

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

57) 

58 

59 

60class _MemfdCreationError(OSError): 

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

62 

63 pass 

64 

65 

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

67 if path is None: 

68 return True 

69 try: 

70 with open(path, "rb"): 

71 pass 

72 return True 

73 except OSError: 

74 return False 

75 

76 

77@contextlib.contextmanager 

78def secure_cert_key_paths( 

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

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

81 passphrase: Optional[bytes] = None, 

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

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

84 

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

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

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

88 

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

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

91 only raw bytes are written to temporary storage. 

92 

93 Args: 

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

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

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

97 

98 Yields: 

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

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

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

102 

103 Raises: 

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

105 """ 

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

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

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

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

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

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

112 

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

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

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

116 yield cert, key, passphrase 

117 return 

118 

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

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

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

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

123 

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

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

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

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

128 try: 

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

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

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

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

133 ): 

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

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

136 str, key_path or key 

137 ), passphrase 

138 return 

139 except _MemfdCreationError: 

140 pass # Fallback to Tier 3 on failure. 

141 

142 # Tier 3: Fallback Encrypted Temp Files. If in-memory files are not supported 

143 # (macOS/Windows), we write to disk. To protect the key, we encrypt plaintext 

144 # keys on-the-fly and securely wipe the files with null bytes during cleanup. 

145 with _tempfile_cert_key_paths(cert_bytes, key_bytes, passphrase) as ( 

146 cert_path, 

147 key_path, 

148 new_passphrase, 

149 ): 

150 yield cast(str, cert_path or cert), cast(str, key_path or key), new_passphrase 

151 

152 

153def _encrypt_key_if_plaintext( 

154 key_bytes: bytes, passphrase: Optional[bytes] 

155) -> Tuple[bytes, Optional[bytes]]: 

156 """Encrypts a plaintext PEM key if necessary, returning the bytes and passphrase. 

157 

158 If the key is already encrypted, or if parsing/encryption fails, the key is 

159 returned as-is (plaintext) as a fallback. This allows the caller (underlying SSL 

160 context) to attempt loading the key directly and handle any failures. 

161 """ 

162 import cryptography 

163 from cryptography.hazmat.primitives import serialization 

164 import secrets 

165 

166 try: 

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

168 # It's plaintext, encrypt it. 

169 target_passphrase = passphrase 

170 if target_passphrase is None: 

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

172 elif isinstance(target_passphrase, str): 

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

174 

175 encrypted_content = pkey.private_bytes( 

176 encoding=serialization.Encoding.PEM, 

177 format=serialization.PrivateFormat.PKCS8, 

178 encryption_algorithm=serialization.BestAvailableEncryption( 

179 target_passphrase 

180 ), 

181 ) 

182 return encrypted_content, target_passphrase 

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

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

185 return key_bytes, passphrase 

186 

187 

188def _secure_wipe_and_remove(file_path: str): 

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

190 

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

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

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

194 until the drive cleans them up. 

195 """ 

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

197 return 

198 try: 

199 size = os.path.getsize(file_path) 

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

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

202 f.flush() 

203 os.fsync(f.fileno()) 

204 except OSError: 

205 pass # Ignore permission/lock errors during cleanup. 

206 finally: 

207 try: 

208 os.remove(file_path) 

209 except OSError: 

210 pass 

211 

212 

213@contextlib.contextmanager 

214def _memfd_cert_key_paths( 

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

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

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

218 

219 Yields: 

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

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

222 """ 

223 cleanup_fds = [] 

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

225 

226 try: 

227 try: 

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

229 if data is not None: 

230 # MFD_CLOEXEC prevents FD leaks to spawned subprocesses. 

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

232 cleanup_fds.append(fd) 

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

234 f.write(data) 

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

236 else: 

237 paths.append(None) 

238 except (OSError, AttributeError) as exc: 

239 raise _MemfdCreationError( 

240 "Failed to create in-memory virtual files" 

241 ) from exc 

242 

243 cert_path, key_path = paths 

244 yield cert_path, key_path 

245 finally: 

246 # Closing the descriptors automatically frees the RAM allocation. 

247 for fd in cleanup_fds: 

248 try: 

249 os.close(fd) 

250 except OSError: 

251 pass 

252 

253 

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

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

256 try: 

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

258 except BaseException: 

259 try: 

260 os.close(fd) 

261 except OSError: 

262 pass 

263 raise 

264 

265 with f: 

266 f.write(data) 

267 f.flush() 

268 try: 

269 os.fsync(f.fileno()) 

270 except OSError: 

271 pass 

272 

273 

274@contextlib.contextmanager 

275def _tempfile_cert_key_paths( 

276 cert_bytes: Optional[bytes], 

277 key_bytes: Optional[bytes], 

278 passphrase: Optional[bytes], 

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

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

281 

282 Yields: 

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

284 paths and the passphrase needed to load the key. 

285 """ 

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

287 tmp_dir = ( 

288 "/dev/shm" 

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

290 else None 

291 ) 

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

293 new_passphrase = passphrase 

294 cert_data = cert_bytes 

295 key_data = None 

296 if key_bytes is not None: 

297 key_data, new_passphrase = _encrypt_key_if_plaintext(key_bytes, passphrase) 

298 

299 try: 

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

301 if data is not None: 

302 try: 

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

304 except OSError: 

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

306 

307 cleanup_files[i] = path 

308 _write_secure_tempfile(fd, data) 

309 

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

311 finally: 

312 cert_cleanup_path = cleanup_files[0] 

313 key_cleanup_path = cleanup_files[1] 

314 

315 try: 

316 if key_cleanup_path: 

317 _secure_wipe_and_remove(key_cleanup_path) 

318 except Exception: 

319 pass 

320 finally: 

321 if cert_cleanup_path: 

322 try: 

323 if os.path.exists(cert_cleanup_path): 

324 os.remove(cert_cleanup_path) 

325 except OSError: 

326 pass 

327 

328 

329def _check_config_path(config_path): 

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

331 otherwise returns None. 

332 

333 Args: 

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

335 

336 Returns: 

337 str: absolute path if exists and None otherwise. 

338 """ 

339 config_path = path.expanduser(config_path) 

340 if not path.exists(config_path): 

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

342 return None 

343 return config_path 

344 

345 

346def _load_json_file(path): 

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

348 secure connect configurations. 

349 

350 Args: 

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

352 

353 Returns: 

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

355 

356 Raises: 

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

358 """ 

359 try: 

360 with open(path) as f: 

361 json_data = json.load(f) 

362 except ValueError as caught_exc: 

363 new_exc = exceptions.ClientCertError(caught_exc) 

364 raise new_exc from caught_exc 

365 

366 return json_data 

367 

368 

369def _get_workload_cert_and_key( 

370 certificate_config_path=None, include_context_aware=True 

371): 

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

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

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

375 

376 Args: 

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

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

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

380 SecureConnect mTLS configuration. 

381 

382 Returns: 

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

384 bytes in PEM format. 

385 

386 Raises: 

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

388 the certificate or key information. 

389 """ 

390 

391 cert_path, key_path = _get_workload_cert_and_key_paths( 

392 certificate_config_path, include_context_aware 

393 ) 

394 

395 if cert_path is None and key_path is None: 

396 return None, None 

397 

398 return _read_cert_and_key_files(cert_path, key_path) 

399 

400 

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

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

403 

404 1: Explicit override, if set 

405 2: Environment variable, if set 

406 3: Well-known location 

407 

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

409 

410 Args: 

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

412 location and environment variable will be ignored. 

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

414 SecureConnect mTLS configuration. 

415 

416 Returns: 

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

418 """ 

419 

420 source = "function argument" 

421 is_explicit = True 

422 if certificate_config_path is None: 

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

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

425 certificate_config_path = env_path 

426 source = ( 

427 f"environment variable {environment_vars.GOOGLE_API_CERTIFICATE_CONFIG}" 

428 ) 

429 else: 

430 env_path = environ.get( 

431 environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH, 

432 None, 

433 ) 

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

435 certificate_config_path = env_path 

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

437 else: 

438 certificate_config_path = os.path.join( 

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

440 ) 

441 is_explicit = False 

442 

443 certificate_config_path = path.expanduser(certificate_config_path) 

444 if not path.exists(certificate_config_path): 

445 if is_explicit: 

446 _LOGGER.debug( 

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

448 source, 

449 certificate_config_path, 

450 ) 

451 return None 

452 return certificate_config_path 

453 

454 

455def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): 

456 absolute_path = _get_cert_config_path(config_path, include_context_aware) 

457 if absolute_path is None: 

458 return None, None 

459 

460 data = _load_json_file(absolute_path) 

461 

462 if "cert_configs" not in data: 

463 raise exceptions.ClientCertError( 

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

465 absolute_path 

466 ) 

467 ) 

468 cert_configs = data["cert_configs"] 

469 

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

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

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

473 # like SecureConnect instead of throwing an exception. 

474 

475 if "workload" not in cert_configs: 

476 return None, None 

477 workload = cert_configs["workload"] 

478 

479 if "cert_path" not in workload or "key_path" not in workload: 

480 raise exceptions.ClientCertError( 

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

482 absolute_path 

483 ) 

484 ) 

485 cert_path = workload["cert_path"] 

486 key_path = workload["key_path"] 

487 

488 return cert_path, key_path 

489 

490 

491def _read_cert_and_key_files(cert_path, key_path): 

492 cert_data = _read_cert_file(cert_path) 

493 key_data = _read_key_file(key_path) 

494 

495 return cert_data, key_data 

496 

497 

498def _read_cert_file(cert_path): 

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

500 cert_data = cert_file.read() 

501 

502 cert_match = re.findall(_CERT_REGEX, cert_data) 

503 if len(cert_match) != 1: 

504 raise exceptions.ClientCertError( 

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

506 cert_path 

507 ) 

508 ) 

509 return cert_match[0] 

510 

511 

512def _read_key_file(key_path): 

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

514 key_data = key_file.read() 

515 

516 key_match = re.findall(_KEY_REGEX, key_data) 

517 if len(key_match) != 1: 

518 raise exceptions.ClientCertError( 

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

520 key_path 

521 ) 

522 ) 

523 

524 return key_match[0] 

525 

526 

527def _run_cert_provider_command(command, expect_encrypted_key=False): 

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

529 passphrase. 

530 

531 Args: 

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

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

534 

535 Returns: 

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

537 bytes in PEM format and passphrase bytes. 

538 

539 Raises: 

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

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

542 """ 

543 try: 

544 process = subprocess.Popen( 

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

546 ) 

547 stdout, stderr = process.communicate() 

548 except OSError as caught_exc: 

549 new_exc = exceptions.ClientCertError(caught_exc) 

550 raise new_exc from caught_exc 

551 

552 # Check cert provider command execution error. 

553 if process.returncode != 0: 

554 raise exceptions.ClientCertError( 

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

556 ) 

557 

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

559 cert_match = re.findall(_CERT_REGEX, stdout) 

560 if len(cert_match) != 1: 

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

562 key_match = re.findall(_KEY_REGEX, stdout) 

563 if len(key_match) != 1: 

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

565 passphrase_match = re.findall(_PASSPHRASE_REGEX, stdout) 

566 

567 if expect_encrypted_key: 

568 if len(passphrase_match) != 1: 

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

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

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

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

573 

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

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

576 if len(passphrase_match) > 0: 

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

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

579 

580 

581def get_client_ssl_credentials( 

582 generate_encrypted_key=False, 

583 context_aware_metadata_path=CONTEXT_AWARE_METADATA_PATH, 

584 certificate_config_path=None, 

585): 

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

587 

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

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

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

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

592 

593 Args: 

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

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

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

597 affects keys obtained via context_aware_metadata.json. 

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

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

600 

601 Returns: 

602 Tuple[bool, bytes, bytes, bytes]: 

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

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

605 

606 Raises: 

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

608 the cert, key and passphrase. 

609 """ 

610 

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

612 cert, key = _get_workload_cert_and_key(certificate_config_path) 

613 if cert and key: 

614 return True, cert, key, None 

615 

616 # 2. Check for context aware metadata json 

617 metadata_path = _check_config_path(context_aware_metadata_path) 

618 

619 if metadata_path: 

620 metadata_json = _load_json_file(metadata_path) 

621 

622 if _CERT_PROVIDER_COMMAND not in metadata_json: 

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

624 

625 command = metadata_json[_CERT_PROVIDER_COMMAND] 

626 

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

628 command.append("--with_passphrase") 

629 

630 # Execute the command. 

631 cert, key, passphrase = _run_cert_provider_command( 

632 command, expect_encrypted_key=generate_encrypted_key 

633 ) 

634 return True, cert, key, passphrase 

635 

636 return False, None, None, None 

637 

638 

639def get_client_cert_and_key(client_cert_callback=None): 

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

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

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

643 default SSL credentials. 

644 

645 Args: 

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

647 optional callback which returns client certificate bytes and private 

648 key bytes both in PEM format. 

649 

650 Returns: 

651 Tuple[bool, bytes, bytes]: 

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

653 and key bytes both in PEM format. 

654 

655 Raises: 

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

657 the cert and key. 

658 """ 

659 if client_cert_callback: 

660 cert, key = client_cert_callback() 

661 return True, cert, key 

662 

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

664 return has_cert, cert, key 

665 

666 

667def decrypt_private_key(key, passphrase): 

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

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

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

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

672 

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

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

675 callback function accepted by google-auth:: 

676 

677 from google.auth.transport import _mtls_helper 

678 

679 def your_client_cert_function(): 

680 return cert, encrypted_key, passphrase 

681 

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

683 def client_cert_callback(): 

684 cert, encrypted_key, passphrase = your_client_cert_function() 

685 decrypted_key = _mtls_helper.decrypt_private_key(encrypted_key, 

686 passphrase) 

687 return cert, decrypted_key 

688 

689 Args: 

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

691 passphrase (bytes): The passphrase bytes. 

692 

693 Returns: 

694 bytes: The decrypted private key in PEM format. 

695 

696 Raises: 

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

698 """ 

699 if isinstance(key, str): 

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

701 if isinstance(passphrase, str): 

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

703 

704 from cryptography.hazmat.primitives import serialization 

705 

706 # First convert encrypted_key_bytes to PKey object 

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

708 

709 # Then dump the decrypted key bytes 

710 return pkey.private_bytes( 

711 encoding=serialization.Encoding.PEM, 

712 format=serialization.PrivateFormat.PKCS8, 

713 encryption_algorithm=serialization.NoEncryption(), 

714 ) 

715 

716 

717def _check_use_client_cert_env(): 

718 use_client_cert = getenv( 

719 environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE 

720 ) or getenv(environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE) 

721 

722 if use_client_cert: 

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

724 return None 

725 

726 

727def check_use_client_cert(): 

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

729 

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

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

732 will default to False. 

733 If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred 

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

735 GOOGLE_API_CERTIFICATE_CONFIG or CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH, 

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

737 containing a "workload" section. 

738 Otherwise, it returns False. 

739 

740 Returns: 

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

742 """ 

743 env_override = _check_use_client_cert_env() 

744 if env_override is not None: 

745 return env_override 

746 

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

748 

749 # Check if a workload config file exists. 

750 cert_path = _get_cert_config_path(include_context_aware=True) 

751 

752 if cert_path: 

753 try: 

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

755 content = json.load(f) 

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

757 _LOGGER.debug( 

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

759 cert_path, 

760 e, 

761 ) 

762 return False 

763 

764 # Structural validation 

765 if isinstance(content, dict): 

766 cert_configs = content.get("cert_configs") 

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

768 return True 

769 

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

771 _LOGGER.debug( 

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

773 cert_path, 

774 ) 

775 return False 

776 

777 

778def check_parameters_for_unauthorized_response(cached_cert): 

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

780 

781 Args: 

782 cached_cert(bytes): The cached client certificate. 

783 

784 Returns: 

785 bytes: The client callback cert bytes. 

786 bytes: The client callback key bytes. 

787 str: The base64-encoded SHA256 cached fingerprint. 

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

789 """ 

790 call_cert_bytes, call_key_bytes = call_client_cert_callback() 

791 cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes) 

792 current_cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint( 

793 cert_obj 

794 ) 

795 if cached_cert: 

796 cached_fingerprint = _agent_identity_utils.get_cached_cert_fingerprint( 

797 cached_cert 

798 ) 

799 else: 

800 cached_fingerprint = current_cert_fingerprint 

801 return call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint 

802 

803 

804def call_client_cert_callback(): 

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

806 _, cert_bytes, key_bytes, passphrase = get_client_ssl_credentials( 

807 generate_encrypted_key=True 

808 ) 

809 return cert_bytes, key_bytes