diff --git a/acme/messages.py b/acme/messages.py index c6d15bbf1..31acd6000 100644 --- a/acme/messages.py +++ b/acme/messages.py @@ -14,11 +14,15 @@ class Error(jose.JSONObjectWithFields, Exception): """ ERROR_TYPE_NAMESPACE = 'urn:acme:error:' ERROR_TYPE_DESCRIPTIONS = { - 'malformed': 'The request message was malformed', - 'unauthorized': 'The client lacks sufficient authorization', - 'serverInternal': 'The server experienced an internal error', 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', 'badNonce': 'The client sent an unacceptable anti-replay nonce', + 'connection': 'The server could not connect to the client for DV', + 'dnssec': 'The server could not validate a DNSSEC signed domain', + 'malformed': 'The request message was malformed', + 'serverInternal': 'The server experienced an internal error', + 'tls': 'The server experienced a TLS error during DV', + 'unauthorized': 'The client lacks sufficient authorization', + 'unknownHost': 'The server could not resolve a domain name', } typ = jose.Field('type') @@ -220,8 +224,11 @@ class ChallengeBody(ResourceBody): """ __slots__ = ('chall',) uri = jose.Field('uri') - status = jose.Field('status', decoder=Status.from_json) + status = jose.Field('status', decoder=Status.from_json, + omitempty=True, default=STATUS_PENDING) validated = fields.RFC3339Field('validated', omitempty=True) + error = jose.Field('error', decoder=Error.from_json, + omitempty=True, default=None) def to_partial_json(self): jobj = super(ChallengeBody, self).to_partial_json() diff --git a/acme/messages_test.py b/acme/messages_test.py index 9b3c03fbc..dca1cd280 100644 --- a/acme/messages_test.py +++ b/acme/messages_test.py @@ -198,19 +198,29 @@ class ChallengeBodyTest(unittest.TestCase): self.chall = challenges.DNS(token='foo') from acme.messages import ChallengeBody - from acme.messages import STATUS_VALID - self.status = STATUS_VALID + from acme.messages import Error + from acme.messages import STATUS_INVALID + self.status = STATUS_INVALID + error = Error(typ='serverInternal', + detail='Unable to communicate with DNS server') self.challb = ChallengeBody( - uri='http://challb', chall=self.chall, status=self.status) + uri='http://challb', chall=self.chall, status=self.status, + error=error) self.jobj_to = { 'uri': 'http://challb', 'status': self.status, 'type': 'dns', 'token': 'foo', + 'error': error, } self.jobj_from = self.jobj_to.copy() - self.jobj_from['status'] = 'valid' + self.jobj_from['status'] = 'invalid' + self.jobj_from['error'] = { + 'type': 'urn:acme:error:serverInternal', + 'detail': 'Unable to communicate with DNS server', + } + def test_to_partial_json(self): self.assertEqual(self.jobj_to, self.challb.to_partial_json()) diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index c3711a244..d7d590878 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -152,6 +152,9 @@ class AuthHandler(object): # Don't send challenges for None and False authenticator responses if resp: self.network.answer_challenge(achall.challb, resp) + # TODO: answer_challenge returns challr, with URI, + # that can be used in _find_updated_challr + # comparisons... active_achalls.append(achall) if achall.domain in chall_update: chall_update[achall.domain].append(achall) @@ -170,23 +173,27 @@ class AuthHandler(object): while dom_to_check and rounds < max_rounds: # TODO: Use retry-after... time.sleep(min_sleep) + all_failed_achalls = set() for domain in dom_to_check: - comp_challs, failed_challs = self._handle_check( + comp_achalls, failed_achalls = self._handle_check( domain, chall_update[domain]) - if len(comp_challs) == len(chall_update[domain]): + if len(comp_achalls) == len(chall_update[domain]): comp_domains.add(domain) - elif not failed_challs: - for chall in comp_challs: - chall_update[domain].remove(chall) + elif not failed_achalls: + for achall, _ in comp_achalls: + chall_update[domain].remove(achall) # We failed some challenges... damage control else: # Right now... just assume a loss and carry on... if best_effort: comp_domains.add(domain) else: - raise errors.AuthorizationError( - "Failed Authorization procedure for %s" % domain) + all_failed_achalls.update( + updated for _, updated in failed_achalls) + + if all_failed_achalls: + raise errors.FailedChallenges(all_failed_achalls) dom_to_check -= comp_domains comp_domains.clear() @@ -204,32 +211,31 @@ class AuthHandler(object): # Note: if the whole authorization is invalid, the individual failed # challenges will be determined here... for achall in achalls: - status = self._get_chall_status(self.authzr[domain], achall) + updated_achall = achall.update(challb=self._find_updated_challb( + self.authzr[domain], achall)) # This does nothing for challenges that have yet to be decided yet. - if status == messages.STATUS_VALID: - completed.append(achall) - elif status == messages.STATUS_INVALID: - failed.append(achall) + if updated_achall.status == messages.STATUS_VALID: + completed.append((achall, updated_achall)) + elif updated_achall.status == messages.STATUS_INVALID: + failed.append((achall, updated_achall)) return completed, failed - def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use - """Get the status of the challenge. + def _find_updated_challb(self, authzr, achall): # pylint: disable=no-self-use + """Find updated challenge body within Authorization Resource. .. warning:: This assumes only one instance of type of challenge in each challenge resource. - :param authzr: Authorization Resource - :type authzr: :class:`acme.messages.AuthorizationResource` - - :param achall: Annotated challenge for which to get status - :type achall: :class:`letsencrypt.achallenges.AnnotatedChallenge` + :param .AuthorizationResource authzr: Authorization Resource + :param .AnnotatedChallenge achall: Annotated challenge for which + to get status """ for authzr_challb in authzr.body.challenges: if type(authzr_challb.chall) is type(achall.challb.chall): - return authzr_challb.status + return authzr_challb raise errors.AuthorizationError( "Target challenge not found in authorization resource") diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index bdcb92164..f753a29c0 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -15,6 +15,24 @@ class AuthorizationError(Error): """Authorization error.""" +class FailedChallenges(AuthorizationError): + """Failed challenges error. + + :ivar set failed_achalls: Failed `.AnnotatedChallenge` instances. + + """ + def __init__(self, failed_achalls): + assert failed_achalls + self.failed_achalls = failed_achalls + super(FailedChallenges, self).__init__() + + def __str__(self): + return "Failed authorization procedure. {0}".format( + ", ".join( + "{0} ({1}): {2}".format(achall.domain, achall.typ, achall.error) + for achall in self.failed_achalls if achall.error is not None)) + + class ContAuthError(AuthorizationError): """Let's Encrypt Continuity Authenticator error.""" diff --git a/letsencrypt/tests/errors_test.py b/letsencrypt/tests/errors_test.py new file mode 100644 index 000000000..a99d84719 --- /dev/null +++ b/letsencrypt/tests/errors_test.py @@ -0,0 +1,26 @@ +"""Tests for letsencrypt.errors.""" +import unittest + +from acme import messages + +from letsencrypt import achallenges +from letsencrypt.tests import acme_util + + +class FaiiledChallengesTest(unittest.TestCase): + """Tests for letsencrypt.errors.FailedChallenges.""" + + def setUp(self): + from letsencrypt.errors import FailedChallenges + self.error = FailedChallenges(set([achallenges.DNS( + domain="example.com", challb=messages.ChallengeBody( + chall=acme_util.DNS, uri=None, + error=messages.Error(typ="tls", detail="detail")))])) + + def test_str(self): + self.assertTrue(str(self.error).startswith( + "Failed authorization procedure. example.com (dns): tls")) + + +if __name__ == "__main__": + unittest.main() # pragma: no cover