/ 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)