/ letsencrypt / auth_handler.py
auth_handler.py
1 """ACME AuthHandler.""" 2 import itertools 3 import logging 4 import time 5 6 import zope.component 7 8 from acme import challenges 9 from acme import messages 10 11 from letsencrypt import achallenges 12 from letsencrypt import constants 13 from letsencrypt import errors 14 from letsencrypt import error_handler 15 from letsencrypt import interfaces 16 17 18 logger = logging.getLogger(__name__) 19 20 21 class AuthHandler(object): 22 """ACME Authorization Handler for a client. 23 24 :ivar dv_auth: Authenticator capable of solving 25 :class:`~acme.challenges.DVChallenge` types 26 :type dv_auth: :class:`letsencrypt.interfaces.IAuthenticator` 27 28 :ivar cont_auth: Authenticator capable of solving 29 :class:`~acme.challenges.ContinuityChallenge` types 30 :type cont_auth: :class:`letsencrypt.interfaces.IAuthenticator` 31 32 :ivar acme.client.Client acme: ACME client API. 33 34 :ivar account: Client's Account 35 :type account: :class:`letsencrypt.account.Account` 36 37 :ivar dict authzr: ACME Authorization Resource dict where keys are domains 38 and values are :class:`acme.messages.AuthorizationResource` 39 :ivar list dv_c: DV challenges in the form of 40 :class:`letsencrypt.achallenges.AnnotatedChallenge` 41 :ivar list cont_c: Continuity challenges in the 42 form of :class:`letsencrypt.achallenges.AnnotatedChallenge` 43 44 """ 45 def __init__(self, dv_auth, cont_auth, acme, account): 46 self.dv_auth = dv_auth 47 self.cont_auth = cont_auth 48 self.acme = acme 49 50 self.account = account 51 self.authzr = dict() 52 53 # List must be used to keep responses straight. 54 self.dv_c = [] 55 self.cont_c = [] 56 57 def get_authorizations(self, domains, best_effort=False): 58 """Retrieve all authorizations for challenges. 59 60 :param set domains: Domains for authorization 61 :param bool best_effort: Whether or not all authorizations are 62 required (this is useful in renewal) 63 64 :returns: tuple of lists of authorization resources. Takes the 65 form of (`completed`, `failed`) 66 :rtype: tuple 67 68 :raises .AuthorizationError: If unable to retrieve all 69 authorizations 70 71 """ 72 for domain in domains: 73 self.authzr[domain] = self.acme.request_domain_challenges( 74 domain, self.account.regr.new_authzr_uri) 75 76 self._choose_challenges(domains) 77 78 # While there are still challenges remaining... 79 while self.dv_c or self.cont_c: 80 cont_resp, dv_resp = self._solve_challenges() 81 logger.info("Waiting for verification...") 82 83 # Send all Responses - this modifies dv_c and cont_c 84 self._respond(cont_resp, dv_resp, best_effort) 85 86 # Just make sure all decisions are complete. 87 self.verify_authzr_complete() 88 # Only return valid authorizations 89 return [authzr for authzr in self.authzr.values() 90 if authzr.body.status == messages.STATUS_VALID] 91 92 def _choose_challenges(self, domains): 93 """Retrieve necessary challenges to satisfy server.""" 94 logger.info("Performing the following challenges:") 95 for dom in domains: 96 path = gen_challenge_path( 97 self.authzr[dom].body.challenges, 98 self._get_chall_pref(dom), 99 self.authzr[dom].body.combinations) 100 101 dom_cont_c, dom_dv_c = self._challenge_factory( 102 dom, path) 103 self.dv_c.extend(dom_dv_c) 104 self.cont_c.extend(dom_cont_c) 105 106 def _solve_challenges(self): 107 """Get Responses for challenges from authenticators.""" 108 cont_resp = [] 109 dv_resp = [] 110 with error_handler.ErrorHandler(self._cleanup_challenges): 111 try: 112 if self.cont_c: 113 cont_resp = self.cont_auth.perform(self.cont_c) 114 if self.dv_c: 115 dv_resp = self.dv_auth.perform(self.dv_c) 116 except errors.AuthorizationError: 117 logger.critical("Failure in setting up challenges.") 118 logger.info("Attempting to clean up outstanding challenges...") 119 raise 120 121 assert len(cont_resp) == len(self.cont_c) 122 assert len(dv_resp) == len(self.dv_c) 123 124 return cont_resp, dv_resp 125 126 def _respond(self, cont_resp, dv_resp, best_effort): 127 """Send/Receive confirmation of all challenges. 128 129 .. note:: This method also cleans up the auth_handler state. 130 131 """ 132 # TODO: chall_update is a dirty hack to get around acme-spec #105 133 chall_update = dict() 134 active_achalls = [] 135 active_achalls.extend( 136 self._send_responses(self.dv_c, dv_resp, chall_update)) 137 active_achalls.extend( 138 self._send_responses(self.cont_c, cont_resp, chall_update)) 139 140 # Check for updated status... 141 try: 142 self._poll_challenges(chall_update, best_effort) 143 finally: 144 # This removes challenges from self.dv_c and self.cont_c 145 self._cleanup_challenges(active_achalls) 146 147 def _send_responses(self, achalls, resps, chall_update): 148 """Send responses and make sure errors are handled. 149 150 :param dict chall_update: parameter that is updated to hold 151 authzr -> list of outstanding solved annotated challenges 152 153 """ 154 active_achalls = [] 155 for achall, resp in itertools.izip(achalls, resps): 156 # This line needs to be outside of the if block below to 157 # ensure failed challenges are cleaned up correctly 158 active_achalls.append(achall) 159 160 # Don't send challenges for None and False authenticator responses 161 if resp is not None and resp: 162 self.acme.answer_challenge(achall.challb, resp) 163 # TODO: answer_challenge returns challr, with URI, 164 # that can be used in _find_updated_challr 165 # comparisons... 166 if achall.domain in chall_update: 167 chall_update[achall.domain].append(achall) 168 else: 169 chall_update[achall.domain] = [achall] 170 171 return active_achalls 172 173 def _poll_challenges( 174 self, chall_update, best_effort, min_sleep=3, max_rounds=15): 175 """Wait for all challenge results to be determined.""" 176 dom_to_check = set(chall_update.keys()) 177 comp_domains = set() 178 rounds = 0 179 180 while dom_to_check and rounds < max_rounds: 181 # TODO: Use retry-after... 182 time.sleep(min_sleep) 183 all_failed_achalls = set() 184 for domain in dom_to_check: 185 comp_achalls, failed_achalls = self._handle_check( 186 domain, chall_update[domain]) 187 188 if len(comp_achalls) == len(chall_update[domain]): 189 comp_domains.add(domain) 190 elif not failed_achalls: 191 for achall, _ in comp_achalls: 192 chall_update[domain].remove(achall) 193 # We failed some challenges... damage control 194 else: 195 # Right now... just assume a loss and carry on... 196 if best_effort: 197 comp_domains.add(domain) 198 else: 199 all_failed_achalls.update( 200 updated for _, updated in failed_achalls) 201 202 if all_failed_achalls: 203 _report_failed_challs(all_failed_achalls) 204 raise errors.FailedChallenges(all_failed_achalls) 205 206 dom_to_check -= comp_domains 207 comp_domains.clear() 208 rounds += 1 209 210 def _handle_check(self, domain, achalls): 211 """Returns tuple of ('completed', 'failed').""" 212 completed = [] 213 failed = [] 214 215 self.authzr[domain], _ = self.acme.poll(self.authzr[domain]) 216 if self.authzr[domain].body.status == messages.STATUS_VALID: 217 return achalls, [] 218 219 # Note: if the whole authorization is invalid, the individual failed 220 # challenges will be determined here... 221 for achall in achalls: 222 updated_achall = achall.update(challb=self._find_updated_challb( 223 self.authzr[domain], achall)) 224 225 # This does nothing for challenges that have yet to be decided yet. 226 if updated_achall.status == messages.STATUS_VALID: 227 completed.append((achall, updated_achall)) 228 elif updated_achall.status == messages.STATUS_INVALID: 229 failed.append((achall, updated_achall)) 230 231 return completed, failed 232 233 def _find_updated_challb(self, authzr, achall): # pylint: disable=no-self-use 234 """Find updated challenge body within Authorization Resource. 235 236 .. warning:: This assumes only one instance of type of challenge in 237 each challenge resource. 238 239 :param .AuthorizationResource authzr: Authorization Resource 240 :param .AnnotatedChallenge achall: Annotated challenge for which 241 to get status 242 243 """ 244 for authzr_challb in authzr.body.challenges: 245 if type(authzr_challb.chall) is type(achall.challb.chall): # noqa 246 return authzr_challb 247 raise errors.AuthorizationError( 248 "Target challenge not found in authorization resource") 249 250 def _get_chall_pref(self, domain): 251 """Return list of challenge preferences. 252 253 :param str domain: domain for which you are requesting preferences 254 255 """ 256 # Make sure to make a copy... 257 chall_prefs = [] 258 chall_prefs.extend(self.cont_auth.get_chall_pref(domain)) 259 chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) 260 return chall_prefs 261 262 def _cleanup_challenges(self, achall_list=None): 263 """Cleanup challenges. 264 265 If achall_list is not provided, cleanup all achallenges. 266 267 """ 268 logger.info("Cleaning up challenges") 269 270 if achall_list is None: 271 dv_c = self.dv_c 272 cont_c = self.cont_c 273 else: 274 dv_c = [achall for achall in achall_list 275 if isinstance(achall.chall, challenges.DVChallenge)] 276 cont_c = [achall for achall in achall_list if isinstance( 277 achall.chall, challenges.ContinuityChallenge)] 278 279 if dv_c: 280 self.dv_auth.cleanup(dv_c) 281 for achall in dv_c: 282 self.dv_c.remove(achall) 283 if cont_c: 284 self.cont_auth.cleanup(cont_c) 285 for achall in cont_c: 286 self.cont_c.remove(achall) 287 288 def verify_authzr_complete(self): 289 """Verifies that all authorizations have been decided. 290 291 :returns: Whether all authzr are complete 292 :rtype: bool 293 294 """ 295 for authzr in self.authzr.values(): 296 if (authzr.body.status != messages.STATUS_VALID and 297 authzr.body.status != messages.STATUS_INVALID): 298 raise errors.AuthorizationError("Incomplete authorizations") 299 300 def _challenge_factory(self, domain, path): 301 """Construct Namedtuple Challenges 302 303 :param str domain: domain of the enrollee 304 305 :param list path: List of indices from `challenges`. 306 307 :returns: dv_chall, list of DVChallenge type 308 :class:`letsencrypt.achallenges.Indexed` 309 cont_chall, list of ContinuityChallenge type 310 :class:`letsencrypt.achallenges.Indexed` 311 :rtype: tuple 312 313 :raises .errors.Error: if challenge type is not recognized 314 315 """ 316 dv_chall = [] 317 cont_chall = [] 318 319 for index in path: 320 challb = self.authzr[domain].body.challenges[index] 321 chall = challb.chall 322 323 achall = challb_to_achall(challb, self.account.key, domain) 324 325 if isinstance(chall, challenges.ContinuityChallenge): 326 cont_chall.append(achall) 327 elif isinstance(chall, challenges.DVChallenge): 328 dv_chall.append(achall) 329 330 return cont_chall, dv_chall 331 332 333 def challb_to_achall(challb, account_key, domain): 334 """Converts a ChallengeBody object to an AnnotatedChallenge. 335 336 :param .ChallengeBody challb: ChallengeBody 337 :param .JWK account_key: Authorized Account Key 338 :param str domain: Domain of the challb 339 340 :returns: Appropriate AnnotatedChallenge 341 :rtype: :class:`letsencrypt.achallenges.AnnotatedChallenge` 342 343 """ 344 chall = challb.chall 345 logger.info("%s challenge for %s", chall.typ, domain) 346 347 if isinstance(chall, challenges.DVSNI): 348 return achallenges.DVSNI( 349 challb=challb, domain=domain, account_key=account_key) 350 elif isinstance(chall, challenges.SimpleHTTP): 351 return achallenges.SimpleHTTP( 352 challb=challb, domain=domain, account_key=account_key) 353 elif isinstance(chall, challenges.DNS): 354 return achallenges.DNS(challb=challb, domain=domain) 355 elif isinstance(chall, challenges.RecoveryContact): 356 return achallenges.RecoveryContact( 357 challb=challb, domain=domain) 358 elif isinstance(chall, challenges.ProofOfPossession): 359 return achallenges.ProofOfPossession( 360 challb=challb, domain=domain) 361 362 else: 363 raise errors.Error( 364 "Received unsupported challenge of type: %s", chall.typ) 365 366 367 def gen_challenge_path(challbs, preferences, combinations): 368 """Generate a plan to get authority over the identity. 369 370 .. todo:: This can be possibly be rewritten to use resolved_combinations. 371 372 :param tuple challbs: A tuple of challenges 373 (:class:`acme.messages.Challenge`) from 374 :class:`acme.messages.AuthorizationResource` to be 375 fulfilled by the client in order to prove possession of the 376 identifier. 377 378 :param list preferences: List of challenge preferences for domain 379 (:class:`acme.challenges.Challenge` subclasses) 380 381 :param tuple combinations: A collection of sets of challenges from 382 :class:`acme.messages.Challenge`, each of which would 383 be sufficient to prove possession of the identifier. 384 385 :returns: tuple of indices from ``challenges``. 386 :rtype: tuple 387 388 :raises letsencrypt.errors.AuthorizationError: If a 389 path cannot be created that satisfies the CA given the preferences and 390 combinations. 391 392 """ 393 if combinations: 394 return _find_smart_path(challbs, preferences, combinations) 395 else: 396 return _find_dumb_path(challbs, preferences) 397 398 399 def _find_smart_path(challbs, preferences, combinations): 400 """Find challenge path with server hints. 401 402 Can be called if combinations is included. Function uses a simple 403 ranking system to choose the combo with the lowest cost. 404 405 """ 406 chall_cost = {} 407 max_cost = 1 408 for i, chall_cls in enumerate(preferences): 409 chall_cost[chall_cls] = i 410 max_cost += i 411 412 # max_cost is now equal to sum(indices) + 1 413 414 best_combo = [] 415 # Set above completing all of the available challenges 416 best_combo_cost = max_cost 417 418 combo_total = 0 419 for combo in combinations: 420 for challenge_index in combo: 421 combo_total += chall_cost.get(challbs[ 422 challenge_index].chall.__class__, max_cost) 423 424 if combo_total < best_combo_cost: 425 best_combo = combo 426 best_combo_cost = combo_total 427 428 combo_total = 0 429 430 if not best_combo: 431 msg = ("Client does not support any combination of challenges that " 432 "will satisfy the CA.") 433 logger.fatal(msg) 434 raise errors.AuthorizationError(msg) 435 436 return best_combo 437 438 439 def _find_dumb_path(challbs, preferences): 440 """Find challenge path without server hints. 441 442 Should be called if the combinations hint is not included by the 443 server. This function returns the best path that does not contain 444 multiple mutually exclusive challenges. 445 446 """ 447 assert len(preferences) == len(set(preferences)) 448 449 path = [] 450 satisfied = set() 451 for pref_c in preferences: 452 for i, offered_challb in enumerate(challbs): 453 if (isinstance(offered_challb.chall, pref_c) and 454 is_preferred(offered_challb, satisfied)): 455 path.append(i) 456 satisfied.add(offered_challb) 457 return path 458 459 460 def mutually_exclusive(obj1, obj2, groups, different=False): 461 """Are two objects mutually exclusive?""" 462 for group in groups: 463 obj1_present = False 464 obj2_present = False 465 466 for obj_cls in group: 467 obj1_present |= isinstance(obj1, obj_cls) 468 obj2_present |= isinstance(obj2, obj_cls) 469 470 if obj1_present and obj2_present and ( 471 not different or not isinstance(obj1, obj2.__class__)): 472 return False 473 return True 474 475 476 def is_preferred(offered_challb, satisfied, 477 exclusive_groups=constants.EXCLUSIVE_CHALLENGES): 478 """Return whether or not the challenge is preferred in path.""" 479 for challb in satisfied: 480 if not mutually_exclusive( 481 offered_challb.chall, challb.chall, exclusive_groups, 482 different=True): 483 return False 484 return True 485 486 487 _ERROR_HELP_COMMON = ( 488 "To fix these errors, please make sure that your domain name was entered " 489 "correctly and the DNS A/AAAA record(s) for that domain contains the " 490 "right IP address.") 491 492 493 _ERROR_HELP = { 494 "connection": 495 _ERROR_HELP_COMMON + " Additionally, please check that your computer " 496 "has publicly routable IP address and no firewalls are preventing the " 497 "server from communicating with the client.", 498 "dnssec": 499 _ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for " 500 "your domain, please ensure the signature is valid.", 501 "malformed": 502 "To fix these errors, please make sure that you did not provide any " 503 "invalid information to the client and try running Let's Encrypt " 504 "again.", 505 "serverInternal": 506 "Unfortunately, an error on the ACME server prevented you from completing " 507 "authorization. Please try again later.", 508 "tls": 509 _ERROR_HELP_COMMON + " Additionally, please check that you have an up " 510 "to date TLS configuration that allows the server to communicate with " 511 "the Let's Encrypt client.", 512 "unauthorized": _ERROR_HELP_COMMON, 513 "unknownHost": _ERROR_HELP_COMMON, 514 } 515 516 517 def _report_failed_challs(failed_achalls): 518 """Notifies the user about failed challenges. 519 520 :param set failed_achalls: A set of failed 521 :class:`letsencrypt.achallenges.AnnotatedChallenge`. 522 523 """ 524 problems = dict() 525 for achall in failed_achalls: 526 if achall.error: 527 problems.setdefault(achall.error.typ, []).append(achall) 528 529 reporter = zope.component.getUtility(interfaces.IReporter) 530 for achalls in problems.itervalues(): 531 reporter.add_message( 532 _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY) 533 534 535 def _generate_failed_chall_msg(failed_achalls): 536 """Creates a user friendly error message about failed challenges. 537 538 :param list failed_achalls: A list of failed 539 :class:`letsencrypt.achallenges.AnnotatedChallenge` with the same error 540 type. 541 542 :returns: A formatted error message for the client. 543 :rtype: str 544 545 """ 546 typ = failed_achalls[0].error.typ 547 msg = [ 548 "The following '{0}' errors were reported by the server:".format(typ)] 549 550 problems = dict() 551 for achall in failed_achalls: 552 problems.setdefault(achall.error.description, set()).add(achall.domain) 553 for problem in problems: 554 msg.append("\n\nDomains: ") 555 msg.append(", ".join(sorted(problems[problem]))) 556 msg.append("\nError: {0}".format(problem)) 557 558 if typ in _ERROR_HELP: 559 msg.append("\n\n") 560 msg.append(_ERROR_HELP[typ]) 561 562 return "".join(msg)