Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/google/auth/_agent_identity_utils.py: 20%

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

148 statements  

1# Copyright 2025 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"""Helpers for Agent Identity credentials.""" 

16 

17import base64 

18import hashlib 

19import os 

20import re 

21import stat 

22import time 

23from urllib.parse import quote, urlparse 

24import warnings 

25 

26from google.auth import environment_vars, exceptions 

27 

28CRYPTOGRAPHY_NOT_FOUND_ERROR = ( 

29 "The cryptography library is required for certificate-based authentication." 

30 "Please install it with `pip install google-auth[cryptography]`." 

31) 

32 

33# SPIFFE trust domain patterns for Agent Identities. 

34_AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS = [ 

35 r"^agents\.global\.org-\d+\.system\.id\.goog$", 

36 r"^agents\.global\.proj-\d+\.system\.id\.goog$", 

37 r"^agents-nonprod\.global\.org-\d+\.system\.id\.goog$", 

38 r"^agents-nonprod\.global\.proj-\d+\.system\.id\.goog$", 

39] 

40 

41_WELL_KNOWN_CERT_PATH = "/var/run/secrets/workload-spiffe-credentials/certificates.pem" 

42 

43# Constants for polling the certificate file. 

44_FAST_POLL_CYCLES = 50 

45_FAST_POLL_INTERVAL = 0.1 # 100ms 

46_SLOW_POLL_INTERVAL = 0.5 # 500ms 

47_TOTAL_TIMEOUT = 30 # seconds 

48 

49# Calculate the number of slow poll cycles based on the total timeout. 

50_SLOW_POLL_CYCLES = int( 

51 (_TOTAL_TIMEOUT - (_FAST_POLL_CYCLES * _FAST_POLL_INTERVAL)) / _SLOW_POLL_INTERVAL 

52) 

53 

54_POLLING_INTERVALS = ([_FAST_POLL_INTERVAL] * _FAST_POLL_CYCLES) + ( 

55 [_SLOW_POLL_INTERVAL] * _SLOW_POLL_CYCLES 

56) 

57 

58 

59def _is_certificate_file_ready(path): 

60 """Checks if a file exists, is a regular file, and is not empty.""" 

61 if not path: 

62 return False 

63 try: 

64 # Check if the path points to a regular file and is not empty. 

65 # stat.S_ISREG is used instead of os.path.isfile to avoid swallowing 

66 # PermissionError exceptions, which the caller needs to propagate. 

67 st = os.stat(path) 

68 return stat.S_ISREG(st.st_mode) and st.st_size > 0 

69 except PermissionError: 

70 # Propagate PermissionError to let caller handle it (e.g., return early or fallback) 

71 raise 

72 except OSError: 

73 return False 

74 

75 

76def get_agent_identity_certificate_path(): 

77 """Gets the agent certificate path from the certificate config file. 

78 

79 The path to the certificate config file is read from the 

80 GOOGLE_API_CERTIFICATE_CONFIG environment variable. This function 

81 can optionally trigger polling to handle cases where the environment 

82 variable is set before the files are available on the filesystem. 

83 

84 Returns: 

85 Optional[str]: The path to the agent's certificate file, or None if unavailable. 

86 

87 Raises: 

88 google.auth.exceptions.RefreshError: If the certificate config file 

89 or the certificate file cannot be found after retries. 

90 """ 

91 cert_config_path = os.environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG) 

92 

93 if not cert_config_path: 

94 return None 

95 

96 # We trigger polling only if the config path points to the well-known directory. 

97 # Cloud Run dynamically generates these files in this directory, and both the 

98 # config file and the certificate file may experience a brief startup latency. 

99 # For all other paths, we return early to avoid introducing unnecessary startup 

100 # delays. 

101 well_known_dir = os.path.dirname(_WELL_KNOWN_CERT_PATH) 

102 try: 

103 abs_cert_path = os.path.abspath(cert_config_path) 

104 abs_well_known_dir = os.path.abspath(well_known_dir) 

105 should_poll = ( 

106 os.path.commonpath([abs_well_known_dir, abs_cert_path]) 

107 == abs_well_known_dir 

108 ) 

109 except ValueError: 

110 should_poll = False 

111 

112 return _get_cert_path_with_optional_polling(cert_config_path, should_poll) 

113 

114 

115def _get_cert_path_with_optional_polling(cert_config_path, should_poll): 

116 """Gets the certificate path, optionally polling until it is ready. 

117 

118 Args: 

119 cert_config_path (str): The path to the certificate configuration file. 

120 should_poll (bool): If True, the function will poll for the file and 

121 certificate to be ready. If False, it will check only once and 

122 return early if they are not immediately available. 

123 

124 Returns: 

125 str: The path to the certificate file, or None if unavailable. 

126 

127 Raises: 

128 google.auth.exceptions.RefreshError: If the certificate config file 

129 or the certificate file cannot be found after retries. 

130 """ 

131 has_logged_config_warning = False 

132 has_logged_cert_warning = False 

133 

134 for interval in _POLLING_INTERVALS: 

135 try: 

136 cert_path = _parse_cert_path_from_config(cert_config_path) 

137 

138 if cert_path is None: 

139 return None 

140 

141 if _is_certificate_file_ready(cert_path): 

142 return cert_path 

143 

144 # The config was parsed, but the cert file is not ready yet 

145 if not should_poll: 

146 # If polling is disabled, return early. 

147 return None 

148 

149 if not has_logged_cert_warning: 

150 warnings.warn( 

151 f"Certificate file not ready at {cert_path}. Retrying until startup timeout (up to {_TOTAL_TIMEOUT} seconds total)..." 

152 ) 

153 has_logged_cert_warning = True 

154 

155 except PermissionError as e: 

156 warnings.warn( 

157 f"Permission denied when accessing certificate config or certificate file: {e}. " 

158 "Token binding protection cannot be enabled. Falling back to unbound tokens." 

159 ) 

160 return None 

161 except (IOError, ValueError, KeyError) as e: 

162 if os.path.exists(cert_config_path): 

163 # If the file exists but has invalid JSON or is unreadable, 

164 # we assume it is in its final format and return early (returning None). 

165 return None 

166 

167 if not should_poll: 

168 # If polling is disabled, return early if the file doesn't exist. 

169 return None 

170 

171 if not has_logged_config_warning: 

172 warnings.warn( 

173 f"Certificate config file not found or incomplete: {e} (from " 

174 f"{environment_vars.GOOGLE_API_CERTIFICATE_CONFIG} environment variable). " 

175 f"Retrying until startup timeout (up to {_TOTAL_TIMEOUT} seconds total)..." 

176 ) 

177 has_logged_config_warning = True 

178 

179 # Sleep before the next polling attempt. 

180 time.sleep(interval) 

181 

182 raise exceptions.RefreshError( 

183 "Certificate config or certificate file not found after multiple retries. " 

184 f"Token binding protection is failing. You can turn off this protection by setting " 

185 f"{environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES} to false " 

186 "to fall back to unbound tokens." 

187 ) 

188 

189 

190def _parse_cert_path_from_config(cert_config_path): 

191 """Reads the cert config file and returns the cert_path. 

192 

193 Args: 

194 cert_config_path (str): The path to the certificate configuration file. 

195 

196 Returns: 

197 Optional[str]: The path to the certificate file, or None if not found 

198 in the config. 

199 

200 Raises: 

201 IOError: If the certificate config file cannot be read. 

202 ValueError: If the certificate config file contains invalid JSON. 

203 KeyError: If the certificate config file does not contain the 

204 expected structure. 

205 """ 

206 import json 

207 

208 with open(cert_config_path, "r", encoding="utf-8") as f: 

209 cert_config = json.load(f) 

210 

211 cert_configs = ( 

212 cert_config.get("cert_configs") if isinstance(cert_config, dict) else None 

213 ) 

214 workload_config = ( 

215 cert_configs.get("workload") if isinstance(cert_configs, dict) else None 

216 ) 

217 

218 if not isinstance(workload_config, dict) or "cert_path" not in workload_config: 

219 return None 

220 

221 return workload_config["cert_path"] 

222 

223 

224def get_and_parse_agent_identity_certificate(): 

225 """Gets and parses the agent identity certificate if not opted out. 

226 

227 Checks if the user has opted out of certificate-bound tokens. If not, 

228 it gets the certificate path, reads the file, and parses it. 

229 

230 Returns: 

231 The parsed certificate object if found and not opted out, otherwise None. 

232 """ 

233 # If the user has opted out of cert bound tokens, there is no need to 

234 # look up the certificate. 

235 is_opted_out = ( 

236 os.environ.get( 

237 environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, 

238 "true", 

239 ).lower() 

240 == "false" 

241 ) 

242 if is_opted_out: 

243 return None 

244 

245 # Respect explicit opt-out of mTLS / client certs 

246 from google.auth.transport import _mtls_helper 

247 

248 env_override = _mtls_helper._check_use_client_cert_env() 

249 if env_override is False: 

250 return None 

251 

252 cert_path = get_agent_identity_certificate_path() 

253 if not cert_path: 

254 return None 

255 

256 try: 

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

258 cert_bytes = cert_file.read() 

259 except PermissionError as e: 

260 warnings.warn( 

261 f"Failed to read agent identity certificate file at {cert_path}: {e}. " 

262 "Token binding protection cannot be enabled. Falling back to unbound tokens." 

263 ) 

264 return None 

265 

266 return parse_certificate(cert_bytes) 

267 

268 

269def parse_certificate(cert_bytes): 

270 """Parses a PEM-encoded certificate. 

271 

272 Args: 

273 cert_bytes (bytes): The PEM-encoded certificate bytes. 

274 

275 Returns: 

276 cryptography.x509.Certificate: The parsed certificate object. 

277 """ 

278 try: 

279 from cryptography import x509 

280 

281 return x509.load_pem_x509_certificate(cert_bytes) 

282 except ImportError as e: 

283 raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e 

284 

285 

286def _is_agent_identity_certificate(cert): 

287 """Checks if a certificate is an Agent Identity certificate. 

288 

289 This is determined by checking the Subject Alternative Name (SAN) for a 

290 SPIFFE ID with a trust domain matching Agent Identity patterns. 

291 

292 Args: 

293 cert (cryptography.x509.Certificate): The parsed certificate object. 

294 

295 Returns: 

296 bool: True if the certificate is an Agent Identity certificate, 

297 False otherwise. 

298 """ 

299 try: 

300 from cryptography import x509 

301 from cryptography.x509.oid import ExtensionOID 

302 

303 try: 

304 ext = cert.extensions.get_extension_for_oid( 

305 ExtensionOID.SUBJECT_ALTERNATIVE_NAME 

306 ) 

307 except x509.ExtensionNotFound: 

308 return False 

309 uris = ext.value.get_values_for_type(x509.UniformResourceIdentifier) 

310 

311 for uri in uris: 

312 parsed_uri = urlparse(uri) 

313 if parsed_uri.scheme == "spiffe": 

314 trust_domain = parsed_uri.netloc 

315 for pattern in _AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS: 

316 if re.match(pattern, trust_domain): 

317 return True 

318 return False 

319 except ImportError as e: 

320 raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e 

321 

322 

323def calculate_certificate_fingerprint(cert): 

324 """Calculates the URL-encoded, unpadded, base64-encoded SHA256 hash of a 

325 DER-encoded certificate. 

326 

327 Args: 

328 cert (cryptography.x509.Certificate): The parsed certificate object. 

329 

330 Returns: 

331 str: The URL-encoded, unpadded, base64-encoded SHA256 fingerprint. 

332 """ 

333 try: 

334 from cryptography.hazmat.primitives import serialization 

335 

336 der_cert = cert.public_bytes(serialization.Encoding.DER) 

337 fingerprint = hashlib.sha256(der_cert).digest() 

338 # The certificate fingerprint is generated in two steps to align with GFE's 

339 # expectations and ensure proper URL transmission: 

340 # 1. Standard base64 encoding is applied, and padding ('=') is removed. 

341 # 2. The resulting string is then URL-encoded to handle special characters 

342 # ('+', '/') that would otherwise be misinterpreted in URL parameters. 

343 base64_fingerprint = base64.b64encode(fingerprint).decode("utf-8") 

344 unpadded_base64_fingerprint = base64_fingerprint.rstrip("=") 

345 return quote(unpadded_base64_fingerprint) 

346 except ImportError as e: 

347 raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e 

348 

349 

350def should_request_bound_token(cert): 

351 """Determines if a bound token should be requested. 

352 

353 This is based on the GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES 

354 environment variable and whether the certificate is an agent identity cert. 

355 

356 Args: 

357 cert (cryptography.x509.Certificate): The parsed certificate object. 

358 

359 Returns: 

360 bool: True if a bound token should be requested, False otherwise. 

361 """ 

362 is_agent_cert = _is_agent_identity_certificate(cert) 

363 is_opted_in = ( 

364 os.environ.get( 

365 environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, 

366 "true", 

367 ).lower() 

368 == "true" 

369 ) 

370 if not (is_agent_cert and is_opted_in): 

371 return False 

372 

373 # Respect explicit opt-out of mTLS / client certs 

374 from google.auth.transport import _mtls_helper 

375 

376 env_override = _mtls_helper._check_use_client_cert_env() 

377 if env_override is False: 

378 return False 

379 

380 return True 

381 

382 

383def get_cached_cert_fingerprint(cached_cert): 

384 """Returns the fingerprint of the cached certificate.""" 

385 if cached_cert: 

386 cert_obj = parse_certificate(cached_cert) 

387 cached_cert_fingerprint = calculate_certificate_fingerprint(cert_obj) 

388 else: 

389 raise ValueError("mTLS connection is not configured.") 

390 return cached_cert_fingerprint