From 8a60b870818a11f05858b29cd7c6e70c1e9110c8 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 6 Apr 2015 17:03:07 -0700 Subject: [PATCH 01/40] Towards interoperability --- letsencrypt/client/achallenges.py | 2 +- letsencrypt/client/auth_handler.py | 160 +++++++--------------- letsencrypt/client/client.py | 63 +++++++-- letsencrypt/client/network2.py | 28 ++-- letsencrypt/client/tests/network2_test.py | 2 +- 5 files changed, 112 insertions(+), 143 deletions(-) diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py index cc7c322fe..7bb548dfc 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -29,7 +29,7 @@ class AnnotatedChallenge(jose_util.ImmutableMap): """Client annotated challenge. Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and - annotates with data usfeul for the client. + annotates with data useful for the client. """ acme_type = NotImplemented diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 8e5020dc2..f5825370f 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -6,7 +6,7 @@ import Crypto.PublicKey.RSA from letsencrypt.acme import challenges from letsencrypt.acme import jose -from letsencrypt.acme import messages +from letsencrypt.acme import messages2 from letsencrypt.client import achallenges from letsencrypt.client import constants @@ -26,59 +26,39 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar network: Network object for sending and receiving authorization messages - :type network: :class:`letsencrypt.client.network.Network` + :type network: :class:`letsencrypt.client.network2.Network` :ivar list domains: list of str domains to get authorization :ivar dict authkey: Authorized Keys for each domain. values are of type :class:`letsencrypt.client.le_util.Key` - :ivar dict responses: keys: domain, values: list of responses - (:class:`letsencrypt.acme.challenges.ChallengeResponse`. - :ivar dict msgs: ACME Challenge messages with domain as a key. - :ivar dict paths: optimal path for authorization. eg. paths[domain] - :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of + :ivar dict authzr: ACME Challenge messages with domain as a key. + :ivar list dv_c: Keys - DV challenges in the form of :class:`letsencrypt.client.achallenges.Indexed` - :ivar dict cont_c: Keys - domain, Values are Continuity challenges in the + :ivar list cont_c: Keys - Continuity challenges in the form of :class:`letsencrypt.client.achallenges.Indexed` """ - def __init__(self, dv_auth, cont_auth, network): + def __init__(self, dv_auth, cont_auth, network, authkey): self.dv_auth = dv_auth self.cont_auth = cont_auth self.network = network self.domains = [] - self.authkey = dict() - self.responses = dict() - self.msgs = dict() - self.paths = dict() + self.authkey = authkey + self.authzr = dict() - self.dv_c = dict() - self.cont_c = dict() + self.dv_c = [] + self.cont_c = [] - def add_chall_msg(self, domain, msg, authkey): - """Add a challenge message to the AuthHandler. - - :param str domain: domain for authorization - - :param msg: ACME "challenge" message - :type msg: :class:`letsencrypt.acme.message.Challenge` - - :param authkey: authorized key for the challenge - :type authkey: :class:`letsencrypt.client.le_util.Key` - - """ - if domain in self.domains: - raise errors.LetsEncryptAuthHandlerError( - "Multiple ACMEChallengeMessages for the same domain " - "is not supported.") - self.domains.append(domain) - self.responses[domain] = [None] * len(msg.challenges) - self.msgs[domain] = msg - self.authkey[domain] = authkey - - def get_authorizations(self): + def get_authorizations(self, domains): """Retrieve all authorizations for challenges. + :param set domains: Domains for authorization + + :returns: tuple of lists of authorization resources. Takes the form of + (`completed`, `failed`) + rtype: tuple + :raises LetsEncryptAuthHandlerError: If unable to retrieve all authorizations @@ -143,31 +123,23 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ logging.info("Performing the following challenges:") for dom in self.domains: - self.paths[dom] = gen_challenge_path( - self.msgs[dom].challenges, + path = gen_challenge_path( + self.authzr[dom].challenges, self._get_chall_pref(dom), - self.msgs[dom].combinations) + self.authzr[dom].combinations) - self.dv_c[dom], self.cont_c[dom] = self._challenge_factory( - dom, self.paths[dom]) - - # Flatten challs for authenticator functions and remove index - # Order is important here as we will not expose the outside - # Authenticator to our own indices. - flat_cont = [] - flat_dv = [] - - for dom in self.domains: - flat_cont.extend(ichall.achall for ichall in self.cont_c[dom]) - flat_dv.extend(ichall.achall for ichall in self.dv_c[dom]) + dom_dv_c, dom_cont_c = self._challenge_factory( + dom, path) + self.dv_c.extend(dom_dv_c) + self.cont_c.extend(dom_cont_c) cont_resp = [] dv_resp = [] try: - if flat_cont: - cont_resp = self.cont_auth.perform(flat_cont) - if flat_dv: - dv_resp = self.dv_auth.perform(flat_dv) + if self.cont_c: + cont_resp = self.cont_auth.perform(self.cont_c) + if self.dv_c: + dv_resp = self.dv_auth.perform(self.dv_c) # This will catch both specific types of errors. except errors.LetsEncryptAuthHandlerError as err: logging.critical("Failure in setting up challenges:") @@ -179,33 +151,29 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes raise errors.LetsEncryptAuthHandlerError( "Unable to perform challenges") + assert len(cont_resp) == len(self.cont_c) + assert len(dv_resp) == len(self.dv_c) + logging.info("Ready for verification...") - # Assemble Responses - if cont_resp: - self._assign_responses(cont_resp, self.cont_c) - if dv_resp: - self._assign_responses(dv_resp, self.dv_c) + # Send all Responses + self._respond(cont_resp, dv_resp) - def _assign_responses(self, flat_list, ichall_dict): - """Assign responses from flat_list back to the Indexed dicts. + def _respond(self, cont_resp, dv_resp): + """Send/Recieve confirmation of all challenges. - :param list flat_list: flat_list of responses from an IAuthenticator - :param dict ichall_dict: Master dict mapping all domains to a list of - their associated 'continuity' and 'dv' Indexed challenges, or their - :class:`letsencrypt.client.achallenges.Indexed` list + .. note:: This method also cleans up the auth_handler state. """ - flat_index = 0 - for dom in self.domains: - for ichall in ichall_dict[dom]: - self.responses[dom][ichall.index] = flat_list[flat_index] - flat_index += 1 + completed = [] + for chall, resp in itertools.izip(self.cont_c, cont_resp): + if cont_resp[i]: + self.network.answer_challenge(self.cont_c[i], cont_resp[i]) + for i in range(len(dv_resp)): + if dv_resp[i]: + self.network.answer_challenge(self.dv_c[i], cont_resp[i]) + - def _path_satisfied(self, dom): - """Returns whether a path has been completely satisfied.""" - # Make sure that there are no 'None' or 'False' entries along path. - return all(self.responses[dom][i] for i in self.paths[dom]) def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -218,40 +186,14 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs - def _cleanup_challenges(self, domain): - """Cleanup configuration challenges + def _cleanup_challenges(self): + """Cleanup all configuration challenges.""" + logging.info("Cleaning up all challenges") - :param str domain: domain for which to clean up challenges - - """ - logging.info("Cleaning up challenges for %s", domain) - # These are indexed challenges... give just the challenges to the auth - # Chose to make these lists instead of a generator to make it easier to - # work with... - dv_list = [ichall.achall for ichall in self.dv_c[domain]] - cont_list = [ichall.achall for ichall in self.cont_c[domain]] - if dv_list: - self.dv_auth.cleanup(dv_list) - if cont_list: - self.cont_auth.cleanup(cont_list) - - def _cleanup_state(self, delete_list): - """Cleanup state after an authorization is received. - - :param list delete_list: list of domains in str form - - """ - for domain in delete_list: - del self.msgs[domain] - del self.responses[domain] - del self.paths[domain] - - del self.authkey[domain] - - del self.cont_c[domain] - del self.dv_c[domain] - - self.domains.remove(domain) + if self.dv_c: + self.dv_auth.cleanup(self.dv_c) + if self.cont_c: + self.cont_auth.cleanup(self.cont_c) def _challenge_factory(self, domain, path): """Construct Namedtuple Challenges diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 2fcb45d40..70b0796a1 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -7,6 +7,7 @@ import Crypto.PublicKey.RSA import M2Crypto from letsencrypt.acme import jose +from letsencrypt.acme.jose import jwk from letsencrypt.acme import messages from letsencrypt.client import auth_handler @@ -14,7 +15,7 @@ from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client import network +from letsencrypt.client import network2 from letsencrypt.client import reverter from letsencrypt.client import revoker @@ -27,11 +28,14 @@ class Client(object): """ACME protocol client. :ivar network: Network object for sending and receiving messages - :type network: :class:`letsencrypt.client.network.Network` + :type network: :class:`letsencrypt.client.network2.Network` :ivar authkey: Authorization Key :type authkey: :class:`letsencrypt.client.le_util.Key` + :ivar reg: Registration Resource + :type reg: :class:`letsencrypt.acme.messages2.RegistrationResource` + :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a continuity_authenticator @@ -55,22 +59,49 @@ class Client(object): :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.network = network.Network(config.server) self.authkey = authkey + self.regr = None self.installer = installer + + # TODO: Allow for other alg types besides RS256 + self.network = network2.Network( + config.server+"/acme/new-registration", + jwk.JWKRSA.load(authkey.pem)) self.config = config if dv_auth is not None: cont_auth = continuity_auth.ContinuityAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( - dv_auth, cont_auth, self.network) + dv_auth, cont_auth, self.network, self.authkey) else: self.auth_handler = None + def register(self, email=None, phone=None): + """New Registration with the ACME server. + + :param str email: User's email address + :param str phone: User's phone number + + """ + # TODO: properly format/scrub phone number + details = ( + "mailto:" + email if email is not None else None, + "tel:" + phone if phone is not None else None + ) + + self.regr = self.network.register( + tuple(detail for detail in details if detail is not None)) + + def set_regr(self, regr): + """Set a preexisting registration resource.""" + self.regr = regr + def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. - :param str domains: list of domains to get a certificate + :meth:`.register` must be called before :meth:`.obtain_certificate` + + :param set domains: domains to get a certificate :param csr: CSR must contain requested domains, the key used to generate this CSR can be different than self.authkey @@ -81,16 +112,20 @@ class Client(object): """ if self.auth_handler is None: - logging.warning("Unable to obtain a certificate, because client " - "does not have a valid auth handler.") - - # Request Challenges - for name in domains: - self.auth_handler.add_chall_msg( - name, self.acme_challenge(name), self.authkey) + msg = ("Unable to obtain certificate because authenticator is " + "not set.") + logging.warning(msg) + raise errors.LetsEncryptClientError(msg) + if self.regr is None: + raise errors.LetsEncryptClientError( + "Please register with the ACME server first.") # Perform Challenges/Get Authorizations - self.auth_handler.get_authorizations() + if self.regr.new_authzr_uri: + self.auth_handler.get_authorizations(domains, self.regr) + else: + self.auth_handler.get_authorizations( + domains, self.config.server + "/acme/new-authorization") # Create CSR from names if csr is None: @@ -330,7 +365,7 @@ def init_csr(privkey, names, cert_dir): :param privkey: Key to include in the CSR :type privkey: :class:`letsencrypt.client.le_util.Key` - :param list names: `str` names to include in the CSR + :param set names: `str` names to include in the CSR :param str cert_dir: Certificate save directory. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index c2f535096..4242bb0a6 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -167,7 +167,7 @@ class Network(object): 'contact'].default): """Register. - :param contact: Contact list, as accpeted by `.RegistrationResource` + :param contact: Contact list, as accepted by `.RegistrationResource` :type contact: `tuple` :returns: Registration Resource. @@ -230,25 +230,24 @@ class Network(object): raise errors.UnexpectedUpdate(authzr) return authzr - def request_challenges(self, identifier, regr): + def request_challenges(self, identifier, new_authzr_uri): """Request challenges. :param identifier: Identifier to be challenged. :type identifier: `.messages2.Identifier` - :param regr: Registration Resource. - :type regr: `.RegistrationResource` + :param str new_authzr_uri: new-authorization URI :returns: Authorization Resource. :rtype: `.AuthorizationResource` """ new_authz = messages2.Authorization(identifier=identifier) - response = self._post(regr.new_authzr_uri, self._wrap_in_jws(new_authz)) + response = self._post(new_authzr_uri, self._wrap_in_jws(new_authz)) assert response.status_code == httplib.CREATED # TODO: handle errors return self._authzr_from_response(response, identifier) - def request_domain_challenges(self, domain, regr): + def request_domain_challenges(self, domain, new_authz_uri): """Request challenges for domain names. This is simply a convenience function that wraps around @@ -256,10 +255,14 @@ class Network(object): generic identifiers. :param str domain: Domain name to be challenged. + :param str new_authzr_uri: new-authorization URI + + :returns: Authorization Resource. + :rtype: `.AuthorizationResource` """ return self.request_challenges(messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value=domain), regr) + typ=messages2.IDENTIFIER_FQDN, value=domain), new_authz_uri) def answer_challenge(self, challb, response): """Answer challenge. @@ -289,17 +292,6 @@ class Network(object): raise errors.UnexpectedUpdate(challr.uri) return challr - def answer_challenges(self, challbs, responses): - """Answer multiple challenges. - - .. note:: This is a convenience function to make integration - with old proto code easier and shall probably be removed - once restification is over. - - """ - return [self.answer_challenge(challb, response) - for challb, response in itertools.izip(challbs, responses)] - @classmethod def retry_after(cls, response, default): """Compute next `poll` time based on response ``Retry-After`` header. diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py index c2a7d877a..9bed09ff1 100644 --- a/letsencrypt/client/tests/network2_test.py +++ b/letsencrypt/client/tests/network2_test.py @@ -217,7 +217,7 @@ class NetworkTest(unittest.TestCase): } self._mock_post_get() - self.net.request_challenges(self.identifier, self.regr) + self.net.request_challenges(self.identifier, self.authzr.uri) # TODO: test POST call arguments # TODO: split here and separate test From f5d25c392b3eb43de1ee564bec3761a67c0e63f8 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 7 Apr 2015 14:36:59 -0700 Subject: [PATCH 02/40] separate out functions rename errors --- letsencrypt/client/auth_handler.py | 130 ++++++++++++----------------- letsencrypt/client/errors.py | 8 +- letsencrypt/client/network2.py | 2 + 3 files changed, 59 insertions(+), 81 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index f5825370f..cabb1267a 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -1,4 +1,5 @@ """ACME AuthHandler.""" +import itertools import logging import sys @@ -31,7 +32,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar list domains: list of str domains to get authorization :ivar dict authkey: Authorized Keys for each domain. values are of type :class:`letsencrypt.client.le_util.Key` - :ivar dict authzr: ACME Challenge messages with domain as a key. + :ivar dict authzr: ACME Authorization Resource dict where keys are domains. :ivar list dv_c: Keys - DV challenges in the form of :class:`letsencrypt.client.achallenges.Indexed` :ivar list cont_c: Keys - Continuity challenges in the @@ -59,30 +60,51 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes (`completed`, `failed`) rtype: tuple - :raises LetsEncryptAuthHandlerError: If unable to retrieve all + :raises AuthHandlerError: If unable to retrieve all authorizations """ - progress = True - while self.msgs and progress: - progress = False - self._satisfy_challenges() + self._choose_challenges(domains) + cont_resp, dv_resp = self._get_responses() + logging.info("Ready for verification...") - delete_list = [] + # Send all Responses + self._respond(cont_resp, dv_resp) - for dom in self.domains: - if self._path_satisfied(dom): - self.acme_authorization(dom) - delete_list.append(dom) + def _choose_challenges(self, domains): + logging.info("Performing the following challenges:") + for dom in domains: + path = gen_challenge_path( + self.authzr[dom].challenges, + self._get_chall_pref(dom), + self.authzr[dom].combinations) - # This avoids modifying while iterating over the list - if delete_list: - self._cleanup_state(delete_list) - progress = True + dom_dv_c, dom_cont_c = self._challenge_factory( + dom, path) + self.dv_c.extend(dom_dv_c) + self.cont_c.extend(dom_cont_c) - if not progress: - raise errors.LetsEncryptAuthHandlerError( - "Unable to solve challenges for requested names.") + def _get_responses(self): + """Get Responses for challenges from authenticators.""" + cont_resp = [] + dv_resp = [] + try: + if self.cont_c: + cont_resp = self.cont_auth.perform(self.cont_c) + if self.dv_c: + dv_resp = self.dv_auth.perform(self.dv_c) + # This will catch both specific types of errors. + except errors.AuthHandlerError as err: + logging.critical("Failure in setting up challenges.") + logging.info("Attempting to clean up outstanding challenges...") + self._cleanup_challenges() + raise errors.AuthHandlerError( + "Unable to perform challenges") + + assert len(cont_resp) == len(self.cont_c) + assert len(dv_resp) == len(self.dv_c) + + return cont_resp, dv_resp def acme_authorization(self, domain): """Handle ACME "authorization" phase. @@ -113,67 +135,22 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes finally: self._cleanup_challenges(domain) - def _satisfy_challenges(self): - """Attempt to satisfy all saved challenge messages. - - .. todo:: It might be worth it to try different challenges to - find one that doesn't throw an exception - .. todo:: separate into more functions - - """ - logging.info("Performing the following challenges:") - for dom in self.domains: - path = gen_challenge_path( - self.authzr[dom].challenges, - self._get_chall_pref(dom), - self.authzr[dom].combinations) - - dom_dv_c, dom_cont_c = self._challenge_factory( - dom, path) - self.dv_c.extend(dom_dv_c) - self.cont_c.extend(dom_cont_c) - - cont_resp = [] - dv_resp = [] - try: - if self.cont_c: - cont_resp = self.cont_auth.perform(self.cont_c) - if self.dv_c: - dv_resp = self.dv_auth.perform(self.dv_c) - # This will catch both specific types of errors. - except errors.LetsEncryptAuthHandlerError as err: - logging.critical("Failure in setting up challenges:") - logging.critical(str(err)) - logging.info("Attempting to clean up outstanding challenges...") - for dom in self.domains: - self._cleanup_challenges(dom) - - raise errors.LetsEncryptAuthHandlerError( - "Unable to perform challenges") - - assert len(cont_resp) == len(self.cont_c) - assert len(dv_resp) == len(self.dv_c) - - logging.info("Ready for verification...") - - # Send all Responses - self._respond(cont_resp, dv_resp) - def _respond(self, cont_resp, dv_resp): """Send/Recieve confirmation of all challenges. .. note:: This method also cleans up the auth_handler state. """ - completed = [] - for chall, resp in itertools.izip(self.cont_c, cont_resp): - if cont_resp[i]: - self.network.answer_challenge(self.cont_c[i], cont_resp[i]) - for i in range(len(dv_resp)): - if dv_resp[i]: - self.network.answer_challenge(self.dv_c[i], cont_resp[i]) - + to_check = self._send_responses(self.dv_c, dv_resp) + to_check.update(self._send_responses(self.cont_c, cont_resp)) + def _send_responses(self, achalls, resps): + """Send responses and make sure errors are handled.""" + to_check = dict() + for achall, resp in itertools.izip(achalls, resps): + if resp: + to_check[achall.domain] = self.network.answer_challenge( + achall.chall, resp) def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -181,8 +158,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param str domain: domain for which you are requesting preferences """ - chall_prefs = [] - chall_prefs.extend(self.cont_auth.get_chall_pref(domain)) + chall_prefs = self.cont_auth.get_chall_pref(domain) chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs @@ -216,7 +192,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes cont_chall = [] for index in path: - chall = self.msgs[domain].challenges[index] + chall = self.authzr[domain].challenges[index] if isinstance(chall, challenges.DVSNI): logging.info(" DVSNI challenge for %s.", domain) @@ -276,7 +252,7 @@ def gen_challenge_path(challs, preferences, combinations): :returns: tuple of indices from ``challenges``. :rtype: tuple - :raises letsencrypt.client.errors.LetsEncryptAuthHandlerError: If a + :raises letsencrypt.client.errors.AuthHandlerError: If a path cannot be created that satisfies the CA given the preferences and combinations. @@ -322,7 +298,7 @@ def _find_smart_path(challs, preferences, combinations): msg = ("Client does not support any combination of challenges that " "will satisfy the CA.") logging.fatal(msg) - raise errors.LetsEncryptAuthHandlerError(msg) + raise errors.AuthHandlerError(msg) return best_combo diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index 243326b14..08201f35e 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -18,15 +18,15 @@ class LetsEncryptReverterError(LetsEncryptClientError): # Auth Handler Errors -class LetsEncryptAuthHandlerError(LetsEncryptClientError): - """Let's Encrypt Auth Handler error.""" +class AuthHandlerError(LetsEncryptClientError): + """Auth Handler error.""" -class LetsEncryptContAuthError(LetsEncryptAuthHandlerError): +class LetsEncryptContAuthError(AuthHandlerError): """Let's Encrypt Client Authenticator error.""" -class LetsEncryptDvAuthError(LetsEncryptAuthHandlerError): +class LetsEncryptDvAuthError(AuthHandlerError): """Let's Encrypt DV Authenticator error.""" diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 4242bb0a6..29fe4a911 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -292,6 +292,8 @@ class Network(object): raise errors.UnexpectedUpdate(challr.uri) return challr + def poll_challenge(self, chall): + @classmethod def retry_after(cls, response, default): """Compute next `poll` time based on response ``Retry-After`` header. From 37620ebe39d7e5286cd920ab235e3894086be352 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 10 Apr 2015 23:02:01 -0700 Subject: [PATCH 03/40] works with boulder --- letsencrypt/acme/challenges.py | 42 +++++++---- letsencrypt/acme/jose/json_util.py | 9 ++- letsencrypt/acme/messages2.py | 40 ++++------- letsencrypt/client/achallenges.py | 20 +----- letsencrypt/client/auth_handler.py | 112 +++++++++++++++++------------ letsencrypt/client/client.py | 47 ++++++++---- letsencrypt/client/network2.py | 43 ++++++++--- letsencrypt/scripts/main.py | 3 +- 8 files changed, 182 insertions(+), 134 deletions(-) diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 7a51d7447..cb8badc91 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -5,24 +5,20 @@ import hashlib import Crypto.Random +from letsencrypt.acme import fields from letsencrypt.acme import jose +from letsencrypt.acme import messages2 from letsencrypt.acme import other # pylint: disable=too-few-public-methods -class Challenge(jose.TypedJSONObjectWithFields): - # _fields_to_json | pylint: disable=abstract-method - """ACME challenge.""" - TYPES = {} - - -class ContinuityChallenge(Challenge): # pylint: disable=abstract-method +class ContinuityChallenge(messages2.Challenge): # pylint: disable=abstract-method """Client validation challenges.""" -class DVChallenge(Challenge): # pylint: disable=abstract-method +class DVChallenge(messages2.Challenge): # pylint: disable=abstract-method """Domain validation challenges.""" @@ -41,7 +37,7 @@ class ChallengeResponse(jose.TypedJSONObjectWithFields): return super(ChallengeResponse, cls).from_json(jobj) -@Challenge.register +@messages2.Challenge.register class SimpleHTTPS(DVChallenge): """ACME "simpleHttps" challenge.""" typ = "simpleHttps" @@ -69,7 +65,7 @@ class SimpleHTTPSResponse(ChallengeResponse): return self.URI_TEMPLATE.format(domain=domain, path=self.path) -@Challenge.register +@messages2.Challenge.register class DVSNI(DVChallenge): """ACME "dvsni" challenge. @@ -93,6 +89,9 @@ class DVSNI(DVChallenge): nonce = jose.Field("nonce", encoder=binascii.hexlify, decoder=functools.partial(functools.partial( jose.decode_hex16, size=NONCE_SIZE))) + uri = jose.Field('uri') + status = jose.Field('status', decoder=messages2.Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) @property def nonce_domain(self): @@ -138,7 +137,7 @@ class DVSNIResponse(ChallengeResponse): """Domain name for certificate subjectAltName.""" return self.z(chall) + self.DOMAIN_SUFFIX -@Challenge.register +@messages2.Challenge.register class RecoveryContact(ContinuityChallenge): """ACME "recoveryContact" challenge.""" typ = "recoveryContact" @@ -147,6 +146,10 @@ class RecoveryContact(ContinuityChallenge): success_url = jose.Field("successURL", omitempty=True) contact = jose.Field("contact", omitempty=True) + uri = jose.Field('uri') + status = jose.Field('status', decoder=messages2.Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) + @ChallengeResponse.register class RecoveryContactResponse(ChallengeResponse): @@ -155,11 +158,15 @@ class RecoveryContactResponse(ChallengeResponse): token = jose.Field("token", omitempty=True) -@Challenge.register +@messages2.Challenge.register class RecoveryToken(ContinuityChallenge): """ACME "recoveryToken" challenge.""" typ = "recoveryToken" + uri = jose.Field('uri') + status = jose.Field('status', decoder=messages2.Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) + @ChallengeResponse.register class RecoveryTokenResponse(ChallengeResponse): @@ -168,7 +175,7 @@ class RecoveryTokenResponse(ChallengeResponse): token = jose.Field("token", omitempty=True) -@Challenge.register +@messages2.Challenge.register class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. @@ -180,6 +187,10 @@ class ProofOfPossession(ContinuityChallenge): NONCE_SIZE = 16 + uri = jose.Field('uri') + status = jose.Field('status', decoder=messages2.Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) + class Hints(jose.JSONObjectWithFields): """Hints for "proofOfPossession" challenge. @@ -236,12 +247,15 @@ class ProofOfPossessionResponse(ChallengeResponse): return self.signature.verify(self.nonce) -@Challenge.register +@messages2.Challenge.register class DNS(DVChallenge): """ACME "dns" challenge.""" typ = "dns" token = jose.Field("token") + uri = jose.Field('uri') + status = jose.Field('status', decoder=messages2.Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) @ChallengeResponse.register class DNSResponse(ChallengeResponse): diff --git a/letsencrypt/acme/jose/json_util.py b/letsencrypt/acme/jose/json_util.py index 01eada89c..a025c9b61 100644 --- a/letsencrypt/acme/jose/json_util.py +++ b/letsencrypt/acme/jose/json_util.py @@ -216,7 +216,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): value = getattr(self, slot) if field.omit(value): - logging.debug('Ommiting empty field "%s" (%s)', slot, value) + logging.debug('Omitting empty field "%s" (%s)', slot, value) else: try: jobj[field.json_name] = field.encode(value) @@ -246,6 +246,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): """Deserialize fields from JSON.""" cls._check_required(jobj) fields = {} + for slot, field in cls._fields.iteritems(): if field.json_name not in jobj and field.omitempty: fields[slot] = field.default @@ -372,17 +373,15 @@ class TypedJSONObjectWithFields(JSONObjectWithFields): raise errors.DeserializationError("missing type field") try: - type_cls = cls.TYPES[typ] + return cls.TYPES[typ] except KeyError: raise errors.UnrecognizedTypeError(typ, jobj) - return type_cls - def to_json(self): """Get JSON serializable object. :returns: Serializable JSON object representing ACME typed object. - :meth:`validate` will almost certianly not work, due to reasons + :meth:`validate` will almost certainly not work, due to reasons explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`. :rtype: dict diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index f4c1e9dce..38b2351b4 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -115,6 +115,14 @@ class ResourceBody(jose.JSONObjectWithFields): """ACME Resource Body.""" +class TypedResourceBody(jose.TypedJSONObjectWithFields): + """ACME Resource Body with type.""" + + +class ResourceBody(jose.JSONObjectWithFields): + """ACME Resource Body""" + + class RegistrationResource(Resource): """Registration Resource. @@ -130,7 +138,7 @@ class Registration(ResourceBody): """Registration Resource Body. :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. - :ivar tuple contact: + :ivar tuple contact: Contact information following ACME spec """ @@ -158,41 +166,23 @@ class ChallengeResource(Resource, jose.JSONObjectWithFields): return self.body.uri -class ChallengeBody(ResourceBody): +class Challenge(TypedResourceBody): """Challenge Resource Body. - .. todo:: - Confusingly, this has a similar name to `.challenges.Challenge`, - as well as `.achallenges.AnnotatedChallenge` or - `.achallenges.Indexed`... Once `messages2` and `network2` is - integrated with the rest of the client, this class functionality - will be merged with `.challenges.Challenge`. Meanwhile, - separation allows the ``master`` to be still interoperable with - Node.js server (protocol v00). For the time being use names such - as ``challb`` to distinguish instances of this class from - ``achall`` or ``ichall``. - :ivar letsencrypt.acme.messages2.Status status: :ivar datetime.datetime validated: """ - - __slots__ = ('chall',) + TYPES = {} + # __slots__ = ('chall',) uri = jose.Field('uri') status = jose.Field('status', decoder=Status.from_json) validated = fields.RFC3339Field('validated', omitempty=True) def to_json(self): - jobj = super(ChallengeBody, self).to_json() - jobj.update(self.chall.to_json()) + jobj = super(Challenge, self).to_json() return jobj - @classmethod - def fields_from_json(cls, jobj): - jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) - jobj_fields['chall'] = challenges.Challenge.from_json(jobj) - return jobj_fields - class AuthorizationResource(Resource): """Authorization Resource. @@ -208,7 +198,7 @@ class Authorization(ResourceBody): """Authorization Resource Body. :ivar letsencrypt.acme.messages2.Identifier identifier: - :ivar list challenges: `list` of `Challenge` + :ivar list challenges: `list` of `ChallengeBody` :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` of `int`, as opposed to `list` of `list` from the spec). :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. @@ -235,7 +225,7 @@ class Authorization(ResourceBody): @challenges.decoder def challenges(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(ChallengeBody.from_json(chall) for chall in value) + return tuple(challenges.Challenge.from_json(chall) for chall in value) @property def resolved_combinations(self): diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py index 7bb548dfc..05bd3c67d 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -1,7 +1,6 @@ """Client annotated ACME challenges. -Please use names such as ``achall`` and ``ichall`` (respectively ``achalls`` -and ``ichalls`` for collections) to distiguish from variables "of type" +Please use names such as ``achall`` to distiguish from variables "of type" :class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``):: from letsencrypt.acme import challenges @@ -9,11 +8,10 @@ and ``ichalls`` for collections) to distiguish from variables "of type" chall = challenges.DNS(token='foo') achall = achallenges.DNS(chall=chall, domain='example.com') - ichall = achallenges.Indexed(achall=achall, index=0) Note, that all annotated challenges act as a proxy objects:: - ichall.token == achall.token == chall.token + achall.token == chall.token """ from letsencrypt.acme import challenges @@ -86,17 +84,3 @@ class ProofOfPossession(AnnotatedChallenge): """Client annotated "proofOfPossession" ACME challenge.""" __slots__ = ('chall', 'domain') acme_type = challenges.ProofOfPossession - - -class Indexed(jose_util.ImmutableMap): - """Indexed and annotated ACME challenge. - - Wraps around :class:`AnnotatedChallenge` and annotates with an - ``index`` in order to maintain the proper position of the response - within a larger challenge list. - - """ - __slots__ = ('achall', 'index') - - def __getattr__(self, name): - return getattr(self.achall, name) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index cabb1267a..4af99761f 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -2,6 +2,7 @@ import itertools import logging import sys +import time import Crypto.PublicKey.RSA @@ -51,7 +52,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.dv_c = [] self.cont_c = [] - def get_authorizations(self, domains): + def get_authorizations(self, domains, new_authz_uri): """Retrieve all authorizations for challenges. :param set domains: Domains for authorization @@ -64,6 +65,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes authorizations """ + for domain in domains: + self.authzr[domain] = self.network.request_domain_challenges( + domain, new_authz_uri) self._choose_challenges(domains) cont_resp, dv_resp = self._get_responses() logging.info("Ready for verification...") @@ -71,13 +75,15 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # Send all Responses self._respond(cont_resp, dv_resp) + return self._verify_auths() + def _choose_challenges(self, domains): logging.info("Performing the following challenges:") for dom in domains: path = gen_challenge_path( - self.authzr[dom].challenges, + self.authzr[dom].body.challenges, self._get_chall_pref(dom), - self.authzr[dom].combinations) + self.authzr[dom].body.combinations) dom_dv_c, dom_cont_c = self._challenge_factory( dom, path) @@ -106,51 +112,65 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes return cont_resp, dv_resp - def acme_authorization(self, domain): - """Handle ACME "authorization" phase. + def _verify_auths(self): + time.sleep(6) + for domain in self.authzr: + self.authzr[domain], resp = self.network.poll(self.authzr[domain]) + if self.authzr[domain].body.status == messages2.STATUS_INVALID: + raise errors.AuthHandlerError( + "Unable to retrieve authorization for %s" % domain) - :param str domain: domain that is requesting authorization - - :returns: ACME "authorization" message. - :rtype: :class:`letsencrypt.acme.messages.Authorization` - - """ - try: - auth = self.network.send_and_receive_expected( - messages.AuthorizationRequest.create( - session_id=self.msgs[domain].session_id, - nonce=self.msgs[domain].nonce, - responses=self.responses[domain], - name=domain, - key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( - self.authkey[domain].pem))), - messages.Authorization) - logging.info("Received Authorization for %s", domain) - return auth - except errors.LetsEncryptClientError as err: - logging.fatal(str(err)) - logging.fatal( - "Failed Authorization procedure - cleaning up challenges") - sys.exit(1) - finally: - self._cleanup_challenges(domain) + self._cleanup_challenges() + return [self.authzr[domain] for domain in self.authzr] def _respond(self, cont_resp, dv_resp): - """Send/Recieve confirmation of all challenges. + """Send/Receive confirmation of all challenges. .. note:: This method also cleans up the auth_handler state. """ - to_check = self._send_responses(self.dv_c, dv_resp) - to_check.update(self._send_responses(self.cont_c, cont_resp)) + chall_update = dict() + self._send_responses(self.dv_c, dv_resp, chall_update) + self._send_responses(self.cont_c, cont_resp, chall_update) - def _send_responses(self, achalls, resps): + # self._poll_challenges(chall_update) + + def _send_responses(self, achalls, resps, chall_update): """Send responses and make sure errors are handled.""" - to_check = dict() for achall, resp in itertools.izip(achalls, resps): if resp: - to_check[achall.domain] = self.network.answer_challenge( - achall.chall, resp) + challr = self.network.answer_challenge(achall.chall, resp) + chall_update[achall.domain] = chall_update.get( + achall.domain, []).append(challr) + + # def _poll_challenges(self, chall_update): + # to_check = chall_update.keys() + # completed = [] + # while to_check: + # + # def _handle_to_check(self): + # for domain in to_check: + # self.authzr[domain] = self.network.poll(self.authzr[domain]) + # if self.authzr[domain].status == messages2.STATUS_VALID: + # completed.append(domain) + # if self.authzr[domain].status == messages2.STATUS_INVALID: + # logging.error("Failed authorization for %s", domain) + # raise errors.AuthHandlerError( + # "Failed Authorization for %s" % domain) + # for challr in chall_update[domain]: + # status = self._get_status_of_chall(self.authzr[domain], challr) + # if status == messages2.STATUS_VALID: + # chall_update[domain].remove(challr) + # elif status == messages2.STATUS_INVALID: + # raise errors.AuthHandlerError( + # "Failed %s challenge for domain %s" % ( + # challr.body.chall.typ, domain)) + # + # def _get_status_of_chall(self, authzr, challr): + # for challb in authzr.challenges: + # # TODO: Use better identifiers... instead of type + # if isinstance(challb.chall, challr.body.chall): + # return challb.status def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -192,16 +212,16 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes cont_chall = [] for index in path: - chall = self.authzr[domain].challenges[index] + chall = self.authzr[domain].body.challenges[index] if isinstance(chall, challenges.DVSNI): logging.info(" DVSNI challenge for %s.", domain) achall = achallenges.DVSNI( - chall=chall, domain=domain, key=self.authkey[domain]) + chall=chall, domain=domain, key=self.authkey) elif isinstance(chall, challenges.SimpleHTTPS): logging.info(" SimpleHTTPS challenge for %s.", domain) achall = achallenges.SimpleHTTPS( - chall=chall, domain=domain, key=self.authkey[domain]) + chall=chall, domain=domain, key=self.authkey) elif isinstance(chall, challenges.DNS): logging.info(" DNS challenge for %s.", domain) achall = achallenges.DNS(chall=chall, domain=domain) @@ -211,7 +231,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes achall = achallenges.RecoveryToken(chall=chall, domain=domain) elif isinstance(chall, challenges.RecoveryContact): logging.info(" Recovery Contact Challenge for %s.", domain) - achall = achallenges.RecoveryContact(chall=chall, domain=domain) + achall = achallenges.RecoveryContact( + chall=chall, domain=domain) elif isinstance(chall, challenges.ProofOfPossession): logging.info(" Proof-of-Possession Challenge for %s", domain) achall = achallenges.ProofOfPossession( @@ -219,14 +240,13 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes else: raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: %s", chall.typ) - - ichall = achallenges.Indexed(achall=achall, index=index) + "Received unsupported challenge of type: %s", + chall.typ) if isinstance(chall, challenges.ContinuityChallenge): - cont_chall.append(ichall) + cont_chall.append(achall) elif isinstance(chall, challenges.DVChallenge): - dv_chall.append(ichall) + dv_chall.append(achall) return dv_chall, cont_chall diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 70b0796a1..7d84feb10 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -65,8 +65,9 @@ class Client(object): # TODO: Allow for other alg types besides RS256 self.network = network2.Network( - config.server+"/acme/new-registration", + "https://%s/acme/new-reg" % config.server, jwk.JWKRSA.load(authkey.pem)) + self.config = config if dv_auth is not None: @@ -88,9 +89,18 @@ class Client(object): "mailto:" + email if email is not None else None, "tel:" + phone if phone is not None else None ) + contact_tuple = tuple(detail for detail in details if detail is not None) - self.regr = self.network.register( - tuple(detail for detail in details if detail is not None)) + # TODO: Replace with real info once through testing. + if not contact_tuple: + contact_tuple = ("mailto:letsencrypt-client@letsencrypt.org", + "tel:+12025551212") + self.regr = self.network.register(contact=contact_tuple) + + # If terms of service exist... we need to sign it. + # TODO: Replace the `preview EULA` with this... + if self.regr.terms_of_service: + self.network.agree_to_tos(self.regr) def set_regr(self, regr): """Set a preexisting registration resource.""" @@ -122,21 +132,26 @@ class Client(object): # Perform Challenges/Get Authorizations if self.regr.new_authzr_uri: - self.auth_handler.get_authorizations(domains, self.regr) + authzr = self.auth_handler.get_authorizations( + domains, self.regr.new_authzr_uri) else: - self.auth_handler.get_authorizations( - domains, self.config.server + "/acme/new-authorization") + authzr = self.auth_handler.get_authorizations( + domains, + "https://%s/acme/new-authz" % self.config.server) # Create CSR from names if csr is None: csr = init_csr(self.authkey, domains, self.config.cert_dir) # Retrieve certificate - certificate_msg = self.acme_certificate(csr.data) + certr = self.network.request_issuance( + jose.ComparableX509( + M2Crypto.X509.load_request_der_string(csr.data)), + authzr) # Save Certificate cert_file, chain_file = self.save_certificate( - certificate_msg, self.config.cert_path, self.config.chain_path) + certr, self.config.cert_path, self.config.chain_path) revoker.Revoker.store_cert_key( cert_file, self.authkey.file, self.config) @@ -172,12 +187,12 @@ class Client(object): self.authkey.pem))), messages.Certificate) - def save_certificate(self, certificate_msg, cert_path, chain_path): + def save_certificate(self, certr, cert_path, chain_path): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. - :param certificate_msg: ACME "certificate" message from server. - :type certificate_msg: :class:`letsencrypt.acme.messages.Certificate` + :param certr: ACME "certificate" resource. + :type certr: :class:`letsencrypt.acme.messages.Certificate` :param str cert_path: Path to attempt to save the cert file :param str chain_path: Path to attempt to save the chain file @@ -188,17 +203,19 @@ class Client(object): :raises IOError: If unable to find room to write the cert files """ + # try finally close cert_chain_abspath = None cert_fd, cert_file = le_util.unique_file(cert_path, 0o644) - cert_fd.write(certificate_msg.certificate.as_pem()) + cert_fd.write(certr.body.as_pem()) cert_fd.close() logging.info( "Server issued certificate; certificate written to %s", cert_file) - if certificate_msg.chain: + if certr.cert_chain_uri: + # try finally close + chain_cert = self.network.fetch_chain(certr.cert_chain_uri) chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644) - for cert in certificate_msg.chain: - chain_fd.write(cert.to_pem()) + chain_fd.write(chain_cert.to_pem()) chain_fd.close() logging.info("Cert chain written to %s", chain_fn) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 29fe4a911..aec6f8ddd 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -50,6 +50,7 @@ class Network(object): """ dumps = obj.json_dumps() logging.debug('Serialized JSON: %s', dumps) + print "json_dumps:", dumps return jose.JWS.sign( payload=dumps, key=self.key, alg=self.alg).json_dumps() @@ -74,7 +75,6 @@ class Network(object): """ response_ct = response.headers.get('Content-Type') - try: # TODO: response.json() is called twice, once here, and # once in _get and _post clients @@ -83,6 +83,9 @@ class Network(object): jobj = None if not response.ok: + print response + print response.headers + print response.content if jobj is not None: if response_ct != cls.JSON_ERROR_CONTENT_TYPE: logging.debug( @@ -167,7 +170,7 @@ class Network(object): 'contact'].default): """Register. - :param contact: Contact list, as accepted by `.RegistrationResource` + :param contact: Contact list, as accepted by `.Registration` :type contact: `tuple` :returns: Registration Resource. @@ -213,6 +216,21 @@ class Network(object): raise errors.UnexpectedUpdate(regr) return updated_regr + def agree_to_tos(self, regr): + """Agree to the terms-of-service. + + Agree to the terms-of-service in a Registration Resource. + + :param regr: Registration Resource. + :type regr: `.RegistrationResource` + + :returns: Updated Registration Resource. + :rtype: `.RegistrationResource` + + """ + self.update_registration( + regr.update(body=regr.body.update(agreement=regr.terms_of_service))) + def _authzr_from_response(self, response, identifier, uri=None, new_cert_uri=None): if new_cert_uri is None: @@ -279,20 +297,23 @@ class Network(object): :raises errors.UnexpectedUpdate: """ + print "sendinging challenge to:", challb.uri response = self._post(challb.uri, self._wrap_in_jws(response)) try: authzr_uri = response.links['up']['url'] except KeyError: - raise errors.NetworkError('"up" Link header missing') - challr = messages2.ChallengeResource( + # TODO: Right now Boulder responds with the authorization resource + # instead of a challenge resource... this can be uncommented + # once the error is fixed. + return challb + # raise errors.NetworkError('"up" Link header missing') + challr2 = messages2.ChallengeResource( authzr_uri=authzr_uri, body=messages2.ChallengeBody.from_json(response.json())) # TODO: check that challr.uri == response.headers['Location']? - if challr.uri != challb.uri: - raise errors.UnexpectedUpdate(challr.uri) - return challr - - def poll_challenge(self, chall): + if challr2.uri != challb.uri: + raise errors.UnexpectedUpdate(challb.uri) + return challr2 @classmethod def retry_after(cls, response, default): @@ -352,6 +373,8 @@ class Network(object): """ assert authzrs, "Authorizations list is empty" + logging.debug("Requesting issuance...") + print "Requesting issuance: ", authzrs[0] # TODO: assert len(authzrs) == number of SANs req = messages2.CertificateRequest( @@ -402,7 +425,7 @@ class Network(object): :rtype: `tuple` """ - # priority queue with datetime (based od Retry-After) as key, + # priority queue with datetime (based on Retry-After) as key, # and original Authorization Resource as value waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs] # mapping between original Authorization Resource and the most diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 3b4b7c10d..ecc96b9e0 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -57,7 +57,7 @@ def create_parser(): config_help = lambda name: interfaces.IConfig[name].__doc__ add("-d", "--domains", metavar="DOMAIN", nargs="+") - add("-s", "--server", default="letsencrypt-demo.org:443", + add("-s", "--server", default="www.letsencrypt-demo.org", help=config_help("server")) add("-k", "--authkey", type=read_file, @@ -202,6 +202,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # but this code should be safe on all environments. cert_file = None if auth is not None: + acme.register() cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: acme.deploy_certificate(doms, authkey, cert_file, chain_file) From e9ffaf5793563243fe6282e15f058b1a68671d18 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 13 Apr 2015 17:33:11 -0700 Subject: [PATCH 04/40] Better authorization handling --- letsencrypt/acme/messages2.py | 13 +- letsencrypt/client/auth_handler.py | 213 ++++++++++++++++++----------- letsencrypt/client/client.py | 60 +++----- letsencrypt/client/errors.py | 10 +- letsencrypt/client/network2.py | 14 +- 5 files changed, 165 insertions(+), 145 deletions(-) diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index 38b2351b4..4d4919255 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -1,5 +1,4 @@ """ACME protocol v02 messages.""" -from letsencrypt.acme import challenges from letsencrypt.acme import fields from letsencrypt.acme import jose @@ -111,10 +110,6 @@ class Resource(jose.ImmutableMap): __slots__ = ('body', 'uri') -class ResourceBody(jose.JSONObjectWithFields): - """ACME Resource Body.""" - - class TypedResourceBody(jose.TypedJSONObjectWithFields): """ACME Resource Body with type.""" @@ -153,7 +148,7 @@ class Registration(ResourceBody): class ChallengeResource(Resource, jose.JSONObjectWithFields): """Challenge Resource. - :ivar letsencrypt.acme.messages2.ChallengeBody body: + :ivar letsencrypt.acme.messages2.Challenge body: :ivar str authzr_uri: URI found in the 'up' ``Link`` header. """ @@ -174,7 +169,6 @@ class Challenge(TypedResourceBody): """ TYPES = {} - # __slots__ = ('chall',) uri = jose.Field('uri') status = jose.Field('status', decoder=Status.from_json) validated = fields.RFC3339Field('validated', omitempty=True) @@ -184,6 +178,7 @@ class Challenge(TypedResourceBody): return jobj + class AuthorizationResource(Resource): """Authorization Resource. @@ -198,7 +193,7 @@ class Authorization(ResourceBody): """Authorization Resource Body. :ivar letsencrypt.acme.messages2.Identifier identifier: - :ivar list challenges: `list` of `ChallengeBody` + :ivar list challenges: `list` of `.Challenge` :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` of `int`, as opposed to `list` of `list` from the spec). :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. @@ -225,7 +220,7 @@ class Authorization(ResourceBody): @challenges.decoder def challenges(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(challenges.Challenge.from_json(chall) for chall in value) + return tuple(Challenge.from_json(chall) for chall in value) @property def resolved_combinations(self): diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 4af99761f..e72b1ce40 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -1,13 +1,9 @@ """ACME AuthHandler.""" import itertools import logging -import sys import time -import Crypto.PublicKey.RSA - from letsencrypt.acme import challenges -from letsencrypt.acme import jose from letsencrypt.acme import messages2 from letsencrypt.client import achallenges @@ -30,14 +26,14 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes messages :type network: :class:`letsencrypt.client.network2.Network` - :ivar list domains: list of str domains to get authorization - :ivar dict authkey: Authorized Keys for each domain. - values are of type :class:`letsencrypt.client.le_util.Key` + :ivar authkey: Authorized Keys for domains. + :type authkey: :class:`letsencrypt.client.le_util.Key` + :ivar dict authzr: ACME Authorization Resource dict where keys are domains. - :ivar list dv_c: Keys - DV challenges in the form of - :class:`letsencrypt.client.achallenges.Indexed` - :ivar list cont_c: Keys - Continuity challenges in the - form of :class:`letsencrypt.client.achallenges.Indexed` + :ivar list dv_c: DV challenges in the form of + :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + :ivar list cont_c: Continuity challenges in the + form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge` """ def __init__(self, dv_auth, cont_auth, network, authkey): @@ -45,23 +41,26 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.cont_auth = cont_auth self.network = network - self.domains = [] self.authkey = authkey self.authzr = dict() + # List must be used to keep responses straight. self.dv_c = [] self.cont_c = [] - def get_authorizations(self, domains, new_authz_uri): + def get_authorizations(self, domains, new_authz_uri, best_effort=False): """Retrieve all authorizations for challenges. :param set domains: Domains for authorization + :param str new_authz_uri: Location to get new authorization resources + :param bool best_effort: Whether or not all authorizations are required + (this is useful in renewal) :returns: tuple of lists of authorization resources. Takes the form of (`completed`, `failed`) rtype: tuple - :raises AuthHandlerError: If unable to retrieve all + :raises AuthorizationError: If unable to retrieve all authorizations """ @@ -69,13 +68,16 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.authzr[domain] = self.network.request_domain_challenges( domain, new_authz_uri) self._choose_challenges(domains) - cont_resp, dv_resp = self._get_responses() - logging.info("Ready for verification...") - # Send all Responses - self._respond(cont_resp, dv_resp) + # While there are still challenges remaining... + while self.dv_c or self.cont_c: + cont_resp, dv_resp = self._solve_challenges() + logging.info("Waiting for verification...") - return self._verify_auths() + # Send all Responses - this modifies dv_c and cont_c + self._respond(cont_resp, dv_resp, best_effort) + + return self.authzr.values() def _choose_challenges(self, domains): logging.info("Performing the following challenges:") @@ -90,7 +92,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.dv_c.extend(dom_dv_c) self.cont_c.extend(dom_cont_c) - def _get_responses(self): + def _solve_challenges(self): """Get Responses for challenges from authenticators.""" cont_resp = [] dv_resp = [] @@ -100,11 +102,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes if self.dv_c: dv_resp = self.dv_auth.perform(self.dv_c) # This will catch both specific types of errors. - except errors.AuthHandlerError as err: + except errors.AuthorizationError as err: logging.critical("Failure in setting up challenges.") logging.info("Attempting to clean up outstanding challenges...") self._cleanup_challenges() - raise errors.AuthHandlerError( + raise errors.AuthorizationError( "Unable to perform challenges") assert len(cont_resp) == len(self.cont_c) @@ -112,65 +114,101 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes return cont_resp, dv_resp - def _verify_auths(self): - time.sleep(6) - for domain in self.authzr: - self.authzr[domain], resp = self.network.poll(self.authzr[domain]) - if self.authzr[domain].body.status == messages2.STATUS_INVALID: - raise errors.AuthHandlerError( - "Unable to retrieve authorization for %s" % domain) - - self._cleanup_challenges() - return [self.authzr[domain] for domain in self.authzr] - - def _respond(self, cont_resp, dv_resp): + def _respond(self, cont_resp, dv_resp, best_effort): """Send/Receive confirmation of all challenges. .. note:: This method also cleans up the auth_handler state. """ + # TODO: chall_update is a dirty hack to get around acme-spec #105 chall_update = dict() self._send_responses(self.dv_c, dv_resp, chall_update) self._send_responses(self.cont_c, cont_resp, chall_update) - # self._poll_challenges(chall_update) + # Check for updated status... + self._poll_challenges(chall_update, best_effort) def _send_responses(self, achalls, resps, chall_update): - """Send responses and make sure errors are handled.""" + """Send responses and make sure errors are handled. + + :param dict chall_update: parameter that is updated to hold + authzr -> list of outstanding solved annotated challenges + + """ for achall, resp in itertools.izip(achalls, resps): + # Don't send challenges for None and False authenticator responses if resp: challr = self.network.answer_challenge(achall.chall, resp) - chall_update[achall.domain] = chall_update.get( - achall.domain, []).append(challr) + if achall.domain in chall_update: + chall_update[achall.domain].append(achall) + else: + chall_update[achall.domain] = [achall] - # def _poll_challenges(self, chall_update): - # to_check = chall_update.keys() - # completed = [] - # while to_check: - # - # def _handle_to_check(self): - # for domain in to_check: - # self.authzr[domain] = self.network.poll(self.authzr[domain]) - # if self.authzr[domain].status == messages2.STATUS_VALID: - # completed.append(domain) - # if self.authzr[domain].status == messages2.STATUS_INVALID: - # logging.error("Failed authorization for %s", domain) - # raise errors.AuthHandlerError( - # "Failed Authorization for %s" % domain) - # for challr in chall_update[domain]: - # status = self._get_status_of_chall(self.authzr[domain], challr) - # if status == messages2.STATUS_VALID: - # chall_update[domain].remove(challr) - # elif status == messages2.STATUS_INVALID: - # raise errors.AuthHandlerError( - # "Failed %s challenge for domain %s" % ( - # challr.body.chall.typ, domain)) - # - # def _get_status_of_chall(self, authzr, challr): - # for challb in authzr.challenges: - # # TODO: Use better identifiers... instead of type - # if isinstance(challb.chall, challr.body.chall): - # return challb.status + def _poll_challenges(self, chall_update, best_effort, min_sleep=3): + """Wait for all challenge results to be determined.""" + dom_to_check = set(chall_update.keys()) + comp_domains = set() + + while dom_to_check: + # TODO: Use retry-after... + time.sleep(min_sleep) + for domain in dom_to_check: + comp_challs, failed_challs = self._handle_check( + domain, chall_update[domain]) + + if len(comp_challs) == len(chall_update[domain]): + comp_domains.add(domain) + elif not failed_challs: + for chall in comp_challs: + chall_update[domain].remove(chall) + # We failed some challenges... damage control + else: + # Right now... just assume a loss and carry on... + if best_effort: + # Add to completed list... but remove authzr + del self.authzr[domain] + comp_domains.add(domain) + else: + raise errors.AuthorizationError( + "Failed Authorization procedure for %s" % domain) + + self._cleanup_challenges(comp_challs) + self._cleanup_challenges(failed_challs) + + dom_to_check -= comp_domains + comp_domains.clear() + + def _handle_check(self, domain, achalls): + """Returns tuple of ('completed', 'failed').""" + completed = [] + failed = [] + + self.authzr[domain], _ = self.network.poll(self.authzr[domain]) + if self.authzr[domain].body.status == messages2.STATUS_VALID: + return achalls, [] + + # 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]) + # This does nothing for challenges that have yet to be decided yet. + if status == messages2.STATUS_VALID: + completed.append(achall) + elif status == messages2.STATUS_INVALID: + failed.append(achall) + + return completed, failed + + def _get_chall_status(self, authzr, chall): + """Get the status of the challenge. + + .. warning:: This assumes only one instance of type of challenge in + each challenge resource. + + """ + for authzr_chall in authzr: + if type(authzr_chall) is type(chall): + return chall.status def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -182,14 +220,31 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs - def _cleanup_challenges(self): - """Cleanup all configuration challenges.""" - logging.info("Cleaning up all challenges") + def _cleanup_challenges(self, achall_list=None): + """Cleanup challenges. - if self.dv_c: - self.dv_auth.cleanup(self.dv_c) - if self.cont_c: - self.cont_auth.cleanup(self.cont_c) + If achall_list is not provided, cleanup all achallenges. + + """ + logging.info("Cleaning up challenges") + + if achall_list is None: + dv_c = self.dv_c + cont_c = self.cont_c + else: + dv_c = [achall for achall in achall_list + if isinstance(achall.chall, challenges.DVChallenge)] + cont_c = [achall for achall in achall_list if isinstance( + achall.chall, challenges.ContinuityChallenge)] + + if dv_c: + self.dv_auth.cleanup(dv_c) + for achall in dv_c: + self.dv_c.remove(achall) + if cont_c: + self.cont_auth.cleanup(cont_c) + for achall in cont_c: + self.cont_c.remove(achall) def _challenge_factory(self, domain, path): """Construct Namedtuple Challenges @@ -208,8 +263,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes recognized """ - dv_chall = [] - cont_chall = [] + dv_chall = set() + cont_chall = set() for index in path: chall = self.authzr[domain].body.challenges[index] @@ -244,9 +299,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes chall.typ) if isinstance(chall, challenges.ContinuityChallenge): - cont_chall.append(achall) + cont_chall.add(achall) elif isinstance(chall, challenges.DVChallenge): - dv_chall.append(achall) + dv_chall.add(achall) return dv_chall, cont_chall @@ -272,7 +327,7 @@ def gen_challenge_path(challs, preferences, combinations): :returns: tuple of indices from ``challenges``. :rtype: tuple - :raises letsencrypt.client.errors.AuthHandlerError: If a + :raises letsencrypt.client.errors.AuthorizationError: If a path cannot be created that satisfies the CA given the preferences and combinations. @@ -318,7 +373,7 @@ def _find_smart_path(challs, preferences, combinations): msg = ("Client does not support any combination of challenges that " "will satisfy the CA.") logging.fatal(msg) - raise errors.AuthHandlerError(msg) + raise errors.AuthorizationError(msg) return best_combo diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 7d84feb10..48fc8f8e6 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -3,12 +3,10 @@ import logging import os import sys -import Crypto.PublicKey.RSA import M2Crypto from letsencrypt.acme import jose from letsencrypt.acme.jose import jwk -from letsencrypt.acme import messages from letsencrypt.client import auth_handler from letsencrypt.client import continuity_auth @@ -134,6 +132,8 @@ class Client(object): if self.regr.new_authzr_uri: authzr = self.auth_handler.get_authorizations( domains, self.regr.new_authzr_uri) + # This isn't required to be in the registration resource... + # and it isn't standardized... ugh - acme-spec #93 else: authzr = self.auth_handler.get_authorizations( domains, @@ -158,35 +158,6 @@ class Client(object): return cert_file, chain_file - def acme_challenge(self, domain): - """Handle ACME "challenge" phase. - - :returns: ACME "challenge" message. - :rtype: :class:`letsencrypt.acme.messages.Challenge` - - """ - return self.network.send_and_receive_expected( - messages.ChallengeRequest(identifier=domain), - messages.Challenge) - - def acme_certificate(self, csr_der): - """Handle ACME "certificate" phase. - - :param str csr_der: CSR in DER format. - - :returns: ACME "certificate" message. - :rtype: :class:`letsencrypt.acme.message.Certificate` - - """ - logging.info("Preparing and sending CSR...") - return self.network.send_and_receive_expected( - messages.CertificateRequest.create( - csr=jose.ComparableX509( - M2Crypto.X509.load_request_der_string(csr_der)), - key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( - self.authkey.pem))), - messages.Certificate) - def save_certificate(self, certr, cert_path, chain_path): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. @@ -205,25 +176,30 @@ class Client(object): """ # try finally close cert_chain_abspath = None - cert_fd, cert_file = le_util.unique_file(cert_path, 0o644) - cert_fd.write(certr.body.as_pem()) - cert_fd.close() + cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) + # TODO: Except + try: + cert_file.write(certr.body.as_pem()) + finally: + cert_file.close() logging.info( - "Server issued certificate; certificate written to %s", cert_file) + "Server issued certificate; certificate written to %s", cert_path) if certr.cert_chain_uri: - # try finally close + # TODO: Except chain_cert = self.network.fetch_chain(certr.cert_chain_uri) - chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644) - chain_fd.write(chain_cert.to_pem()) - chain_fd.close() + chain_file, act_chain_path = le_util.unique_file(chain_path, 0o644) + try: + chain_file.write(chain_cert.to_pem()) + finally: + chain_file.close() - logging.info("Cert chain written to %s", chain_fn) + logging.info("Cert chain written to %s", act_chain_path) # This expects a valid chain file - cert_chain_abspath = os.path.abspath(chain_fn) + cert_chain_abspath = os.path.abspath(act_chain_path) - return os.path.abspath(cert_file), cert_chain_abspath + return os.path.abspath(act_cert_path), cert_chain_abspath def deploy_certificate(self, domains, privkey, cert_file, chain_file=None): """Install certificate diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index 08201f35e..f5d9f5f44 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -18,15 +18,15 @@ class LetsEncryptReverterError(LetsEncryptClientError): # Auth Handler Errors -class AuthHandlerError(LetsEncryptClientError): - """Auth Handler error.""" +class AuthorizationError(LetsEncryptClientError): + """Authorization error.""" -class LetsEncryptContAuthError(AuthHandlerError): - """Let's Encrypt Client Authenticator error.""" +class LetsEncryptContAuthError(AuthorizationError): + """Let's Encrypt Continuity Authenticator error.""" -class LetsEncryptDvAuthError(AuthHandlerError): +class LetsEncryptDvAuthError(AuthorizationError): """Let's Encrypt DV Authenticator error.""" diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index aec6f8ddd..534cac14b 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -2,7 +2,6 @@ import datetime import heapq import httplib -import itertools import logging import time @@ -50,7 +49,6 @@ class Network(object): """ dumps = obj.json_dumps() logging.debug('Serialized JSON: %s', dumps) - print "json_dumps:", dumps return jose.JWS.sign( payload=dumps, key=self.key, alg=self.alg).json_dumps() @@ -83,9 +81,6 @@ class Network(object): jobj = None if not response.ok: - print response - print response.headers - print response.content if jobj is not None: if response_ct != cls.JSON_ERROR_CONTENT_TYPE: logging.debug( @@ -93,6 +88,7 @@ class Network(object): response_ct) try: + # TODO: This is insufficient or doesn't work as intended. raise messages2.Error.from_json(jobj) except jose.DeserializationError as error: # Couldn't deserialize JSON object @@ -297,7 +293,6 @@ class Network(object): :raises errors.UnexpectedUpdate: """ - print "sendinging challenge to:", challb.uri response = self._post(challb.uri, self._wrap_in_jws(response)) try: authzr_uri = response.links['up']['url'] @@ -307,13 +302,13 @@ class Network(object): # once the error is fixed. return challb # raise errors.NetworkError('"up" Link header missing') - challr2 = messages2.ChallengeResource( + challr = messages2.ChallengeResource( authzr_uri=authzr_uri, body=messages2.ChallengeBody.from_json(response.json())) # TODO: check that challr.uri == response.headers['Location']? - if challr2.uri != challb.uri: + if challr.uri != challb.uri: raise errors.UnexpectedUpdate(challb.uri) - return challr2 + return challr @classmethod def retry_after(cls, response, default): @@ -374,7 +369,6 @@ class Network(object): """ assert authzrs, "Authorizations list is empty" logging.debug("Requesting issuance...") - print "Requesting issuance: ", authzrs[0] # TODO: assert len(authzrs) == number of SANs req = messages2.CertificateRequest( From 8857cb738bb70f84f4e697662fd95dd45ca82016 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 14 Apr 2015 02:13:12 -0700 Subject: [PATCH 05/40] fix get_chall_status call --- letsencrypt/client/auth_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index e72b1ce40..fce5fd87a 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -190,7 +190,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # 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]) + status = self._get_chall_status(self.authzr[domain], achall) # This does nothing for challenges that have yet to be decided yet. if status == messages2.STATUS_VALID: completed.append(achall) From 458a61a177e39057377cdce82c9401a197250539 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 14 Apr 2015 06:44:57 +0000 Subject: [PATCH 06/40] Revert to ChallengeResource/ChallengeBody/Challenge triplet --- letsencrypt/acme/challenges.py | 42 ++++++++++++---------------------- letsencrypt/acme/messages2.py | 31 ++++++++++++++++--------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index cb8badc91..7a51d7447 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -5,20 +5,24 @@ import hashlib import Crypto.Random -from letsencrypt.acme import fields from letsencrypt.acme import jose -from letsencrypt.acme import messages2 from letsencrypt.acme import other # pylint: disable=too-few-public-methods -class ContinuityChallenge(messages2.Challenge): # pylint: disable=abstract-method +class Challenge(jose.TypedJSONObjectWithFields): + # _fields_to_json | pylint: disable=abstract-method + """ACME challenge.""" + TYPES = {} + + +class ContinuityChallenge(Challenge): # pylint: disable=abstract-method """Client validation challenges.""" -class DVChallenge(messages2.Challenge): # pylint: disable=abstract-method +class DVChallenge(Challenge): # pylint: disable=abstract-method """Domain validation challenges.""" @@ -37,7 +41,7 @@ class ChallengeResponse(jose.TypedJSONObjectWithFields): return super(ChallengeResponse, cls).from_json(jobj) -@messages2.Challenge.register +@Challenge.register class SimpleHTTPS(DVChallenge): """ACME "simpleHttps" challenge.""" typ = "simpleHttps" @@ -65,7 +69,7 @@ class SimpleHTTPSResponse(ChallengeResponse): return self.URI_TEMPLATE.format(domain=domain, path=self.path) -@messages2.Challenge.register +@Challenge.register class DVSNI(DVChallenge): """ACME "dvsni" challenge. @@ -89,9 +93,6 @@ class DVSNI(DVChallenge): nonce = jose.Field("nonce", encoder=binascii.hexlify, decoder=functools.partial(functools.partial( jose.decode_hex16, size=NONCE_SIZE))) - uri = jose.Field('uri') - status = jose.Field('status', decoder=messages2.Status.from_json) - validated = fields.RFC3339Field('validated', omitempty=True) @property def nonce_domain(self): @@ -137,7 +138,7 @@ class DVSNIResponse(ChallengeResponse): """Domain name for certificate subjectAltName.""" return self.z(chall) + self.DOMAIN_SUFFIX -@messages2.Challenge.register +@Challenge.register class RecoveryContact(ContinuityChallenge): """ACME "recoveryContact" challenge.""" typ = "recoveryContact" @@ -146,10 +147,6 @@ class RecoveryContact(ContinuityChallenge): success_url = jose.Field("successURL", omitempty=True) contact = jose.Field("contact", omitempty=True) - uri = jose.Field('uri') - status = jose.Field('status', decoder=messages2.Status.from_json) - validated = fields.RFC3339Field('validated', omitempty=True) - @ChallengeResponse.register class RecoveryContactResponse(ChallengeResponse): @@ -158,15 +155,11 @@ class RecoveryContactResponse(ChallengeResponse): token = jose.Field("token", omitempty=True) -@messages2.Challenge.register +@Challenge.register class RecoveryToken(ContinuityChallenge): """ACME "recoveryToken" challenge.""" typ = "recoveryToken" - uri = jose.Field('uri') - status = jose.Field('status', decoder=messages2.Status.from_json) - validated = fields.RFC3339Field('validated', omitempty=True) - @ChallengeResponse.register class RecoveryTokenResponse(ChallengeResponse): @@ -175,7 +168,7 @@ class RecoveryTokenResponse(ChallengeResponse): token = jose.Field("token", omitempty=True) -@messages2.Challenge.register +@Challenge.register class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. @@ -187,10 +180,6 @@ class ProofOfPossession(ContinuityChallenge): NONCE_SIZE = 16 - uri = jose.Field('uri') - status = jose.Field('status', decoder=messages2.Status.from_json) - validated = fields.RFC3339Field('validated', omitempty=True) - class Hints(jose.JSONObjectWithFields): """Hints for "proofOfPossession" challenge. @@ -247,15 +236,12 @@ class ProofOfPossessionResponse(ChallengeResponse): return self.signature.verify(self.nonce) -@messages2.Challenge.register +@Challenge.register class DNS(DVChallenge): """ACME "dns" challenge.""" typ = "dns" token = jose.Field("token") - uri = jose.Field('uri') - status = jose.Field('status', decoder=messages2.Status.from_json) - validated = fields.RFC3339Field('validated', omitempty=True) @ChallengeResponse.register class DNSResponse(ChallengeResponse): diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index 4d4919255..116f2fe94 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -1,4 +1,5 @@ """ACME protocol v02 messages.""" +from letsencrypt.acme import challenges from letsencrypt.acme import fields from letsencrypt.acme import jose @@ -110,12 +111,8 @@ class Resource(jose.ImmutableMap): __slots__ = ('body', 'uri') -class TypedResourceBody(jose.TypedJSONObjectWithFields): - """ACME Resource Body with type.""" - - class ResourceBody(jose.JSONObjectWithFields): - """ACME Resource Body""" + """ACME Resource Body.""" class RegistrationResource(Resource): @@ -148,7 +145,7 @@ class Registration(ResourceBody): class ChallengeResource(Resource, jose.JSONObjectWithFields): """Challenge Resource. - :ivar letsencrypt.acme.messages2.Challenge body: + :ivar letsencrypt.acme.messages2.ChallengeBody body: :ivar str authzr_uri: URI found in the 'up' ``Link`` header. """ @@ -161,22 +158,34 @@ class ChallengeResource(Resource, jose.JSONObjectWithFields): return self.body.uri -class Challenge(TypedResourceBody): +class ChallengeBody(ResourceBody): """Challenge Resource Body. + .. todo:: + Confusingly, this has a similar name to `.challenges.Challenge`, + as well as `.achallenges.AnnotateChallenge`. Please use names + such as ``challb`` to distinguish instanced of this class from + ``achall``. + :ivar letsencrypt.acme.messages2.Status status: :ivar datetime.datetime validated: """ - TYPES = {} + __slots__ = ('chall',) uri = jose.Field('uri') status = jose.Field('status', decoder=Status.from_json) validated = fields.RFC3339Field('validated', omitempty=True) def to_json(self): - jobj = super(Challenge, self).to_json() + jobj = super(ChallengeBody, self).to_json() + jobj.update(self.chall.to_json()) return jobj + @classmethod + def fields_from_json(cls, jobj): + jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) + jobj_fields['chall'] = challenges.Challenge.from_json(jobj) + return jobj_fields class AuthorizationResource(Resource): @@ -193,7 +202,7 @@ class Authorization(ResourceBody): """Authorization Resource Body. :ivar letsencrypt.acme.messages2.Identifier identifier: - :ivar list challenges: `list` of `.Challenge` + :ivar list challenges: `list` of `.ChallengeBody` :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` of `int`, as opposed to `list` of `list` from the spec). :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. @@ -220,7 +229,7 @@ class Authorization(ResourceBody): @challenges.decoder def challenges(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(Challenge.from_json(chall) for chall in value) + return tuple(ChallengeBody.from_json(chall) for chall in value) @property def resolved_combinations(self): From 5298d8123d3a6302ac9af71d156548097de6e060 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 14 Apr 2015 13:05:01 +0000 Subject: [PATCH 07/40] ChallengeBody __getattr__ proxy --- letsencrypt/acme/messages2.py | 6 ++++++ letsencrypt/acme/messages2_test.py | 3 +++ letsencrypt/client/achallenges.py | 7 +++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index 116f2fe94..b04291af3 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -167,6 +167,9 @@ class ChallengeBody(ResourceBody): such as ``challb`` to distinguish instanced of this class from ``achall``. + :ivar letsencrypt.acme.challenges.Challenge: Wrapped challenge. + Conveniently, all challenge fields are proxied, i.e. you can + call ``challb.x`` to get ``challb.chall.x`` contents. :ivar letsencrypt.acme.messages2.Status status: :ivar datetime.datetime validated: @@ -187,6 +190,9 @@ class ChallengeBody(ResourceBody): jobj_fields['chall'] = challenges.Challenge.from_json(jobj) return jobj_fields + def __getattr__(self, name): + return getattr(self.chall, name) + class AuthorizationResource(Resource): """Authorization Resource. diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py index 5297d6362..614895b98 100644 --- a/letsencrypt/acme/messages2_test.py +++ b/letsencrypt/acme/messages2_test.py @@ -103,6 +103,9 @@ class ChallengeBodyTest(unittest.TestCase): from letsencrypt.acme.messages2 import ChallengeBody self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from)) + def test_getattr_proxy(self): + self.assertEqual('foo', self.challb.token) + class AuthorizationTest(unittest.TestCase): """Tests for letsencrypt.acme.messages2.Authorization.""" diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py index 05bd3c67d..707e9c867 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -26,10 +26,13 @@ from letsencrypt.client import crypto_util class AnnotatedChallenge(jose_util.ImmutableMap): """Client annotated challenge. - Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and - annotates with data useful for the client. + Wraps around server provided challenge and annotates with data + useful for the client. + + :ivar chall: Wrapped `~.ChallengeBody` (or just `~.challenges.Challenge`). """ + __slots__ = ('chall',) acme_type = NotImplemented def __getattr__(self, name): From 1672e07b2c041b2f1d24a7b28a50183bd318366a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 14 Apr 2015 13:06:00 +0000 Subject: [PATCH 08/40] Return RegistrationResource in agree_to_tos --- letsencrypt/client/network2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 534cac14b..7a50a40bf 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -224,7 +224,7 @@ class Network(object): :rtype: `.RegistrationResource` """ - self.update_registration( + return self.update_registration( regr.update(body=regr.body.update(agreement=regr.terms_of_service))) def _authzr_from_response(self, response, identifier, From fc52600c4d4c8a12522776cd129d2b21b1d99658 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 14 Apr 2015 13:40:56 +0000 Subject: [PATCH 09/40] Adjust achallanges to be used with ChallengeBody --- letsencrypt/client/achallenges.py | 29 +++++++++------- letsencrypt/client/auth_handler.py | 54 ++++++++++++++++-------------- 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py index 707e9c867..1a5cf9c8e 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -1,17 +1,20 @@ """Client annotated ACME challenges. Please use names such as ``achall`` to distiguish from variables "of type" -:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``):: +:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``) +and :class:`.ChallengeBody` (denoted by ``challb``):: from letsencrypt.acme import challenges + from letsencrypt.acme import messages2 from letsencrypt.client import achallenges chall = challenges.DNS(token='foo') - achall = achallenges.DNS(chall=chall, domain='example.com') + challb = messages2.ChallengeBody(chall=chall) + achall = achallenges.DNS(chall=challb, domain='example.com') Note, that all annotated challenges act as a proxy objects:: - achall.token == chall.token + achall.token == challb.token """ from letsencrypt.acme import challenges @@ -29,19 +32,19 @@ class AnnotatedChallenge(jose_util.ImmutableMap): Wraps around server provided challenge and annotates with data useful for the client. - :ivar chall: Wrapped `~.ChallengeBody` (or just `~.challenges.Challenge`). + :ivar challb: Wrapped `~.ChallengeBody`. """ - __slots__ = ('chall',) + __slots__ = ('challb',) acme_type = NotImplemented def __getattr__(self, name): - return getattr(self.chall, name) + return getattr(self.challb, name) class DVSNI(AnnotatedChallenge): """Client annotated "dvsni" ACME challenge.""" - __slots__ = ('chall', 'domain', 'key') + __slots__ = ('challb', 'domain', 'key') acme_type = challenges.DVSNI def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name @@ -55,35 +58,35 @@ class DVSNI(AnnotatedChallenge): """ response = challenges.DVSNIResponse(s=s) cert_pem = crypto_util.make_ss_cert(self.key.pem, [ - self.nonce_domain, self.domain, response.z_domain(self.chall)]) + self.nonce_domain, self.domain, response.z_domain(self.challb)]) return cert_pem, response class SimpleHTTPS(AnnotatedChallenge): """Client annotated "simpleHttps" ACME challenge.""" - __slots__ = ('chall', 'domain', 'key') + __slots__ = ('challb', 'domain', 'key') acme_type = challenges.SimpleHTTPS class DNS(AnnotatedChallenge): """Client annotated "dns" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.DNS class RecoveryContact(AnnotatedChallenge): """Client annotated "recoveryContact" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.RecoveryContact class RecoveryToken(AnnotatedChallenge): """Client annotated "recoveryToken" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.RecoveryToken class ProofOfPossession(AnnotatedChallenge): """Client annotated "proofOfPossession" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.ProofOfPossession diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index fce5fd87a..09c731cf0 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -138,7 +138,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes for achall, resp in itertools.izip(achalls, resps): # Don't send challenges for None and False authenticator responses if resp: - challr = self.network.answer_challenge(achall.chall, resp) + challr = self.network.answer_challenge(achall.challb, resp) if achall.domain in chall_update: chall_update[achall.domain].append(achall) else: @@ -267,31 +267,32 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes cont_chall = set() for index in path: - chall = self.authzr[domain].body.challenges[index] + challb = self.authzr[domain].body.challenges[index] + chall = challb.chall if isinstance(chall, challenges.DVSNI): logging.info(" DVSNI challenge for %s.", domain) achall = achallenges.DVSNI( - chall=chall, domain=domain, key=self.authkey) + challb=challb, domain=domain, key=self.authkey) elif isinstance(chall, challenges.SimpleHTTPS): logging.info(" SimpleHTTPS challenge for %s.", domain) achall = achallenges.SimpleHTTPS( - chall=chall, domain=domain, key=self.authkey) + challb=challb, domain=domain, key=self.authkey) elif isinstance(chall, challenges.DNS): logging.info(" DNS challenge for %s.", domain) - achall = achallenges.DNS(chall=chall, domain=domain) + achall = achallenges.DNS(challb=challb, domain=domain) elif isinstance(chall, challenges.RecoveryToken): logging.info(" Recovery Token Challenge for %s.", domain) - achall = achallenges.RecoveryToken(chall=chall, domain=domain) + achall = achallenges.RecoveryToken(challb=challb, domain=domain) elif isinstance(chall, challenges.RecoveryContact): logging.info(" Recovery Contact Challenge for %s.", domain) achall = achallenges.RecoveryContact( - chall=chall, domain=domain) + challb=challb, domain=domain) elif isinstance(chall, challenges.ProofOfPossession): logging.info(" Proof-of-Possession Challenge for %s", domain) achall = achallenges.ProofOfPossession( - chall=chall, domain=domain) + challb=challb, domain=domain) else: raise errors.LetsEncryptClientError( @@ -306,15 +307,15 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes return dv_chall, cont_chall -def gen_challenge_path(challs, preferences, combinations): +def gen_challenge_path(challbs, preferences, combinations): """Generate a plan to get authority over the identity. .. todo:: This can be possibly be rewritten to use resolved_combinations. - :param tuple challs: A tuple of challenges - (:class:`letsencrypt.acme.challenges.Challenge`) from - :class:`letsencrypt.acme.messages.Challenge` server message to - be fulfilled by the client in order to prove possession of the + :param tuple challbs: A tuple of challenges + (:class:`letsencrypt.acme.messages2.Challenge`) from + :class:`letsencrypt.acme.messages2.AuthorizationResource` to be + fulfilled by the client in order to prove possession of the identifier. :param list preferences: List of challenge preferences for domain @@ -333,12 +334,12 @@ def gen_challenge_path(challs, preferences, combinations): """ if combinations: - return _find_smart_path(challs, preferences, combinations) + return _find_smart_path(challbs, preferences, combinations) else: - return _find_dumb_path(challs, preferences) + return _find_dumb_path(challbs, preferences) -def _find_smart_path(challs, preferences, combinations): +def _find_smart_path(challbs, preferences, combinations): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple @@ -360,8 +361,8 @@ def _find_smart_path(challs, preferences, combinations): combo_total = 0 for combo in combinations: for challenge_index in combo: - combo_total += chall_cost.get(challs[ - challenge_index].__class__, max_cost) + combo_total += chall_cost.get(challbs[ + challenge_index].chall.__class__, max_cost) if combo_total < best_combo_cost: best_combo = combo @@ -378,7 +379,7 @@ def _find_smart_path(challs, preferences, combinations): return best_combo -def _find_dumb_path(challs, preferences): +def _find_dumb_path(challbs, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the @@ -391,11 +392,11 @@ def _find_dumb_path(challs, preferences): path = [] satisfied = set() for pref_c in preferences: - for i, offered_chall in enumerate(challs): - if (isinstance(offered_chall, pref_c) and - is_preferred(offered_chall, satisfied)): + for i, offered_challb in enumerate(challbs): + if (isinstance(offered_challb.chall, pref_c) and + is_preferred(offered_challb, satisfied)): path.append(i) - satisfied.add(offered_chall) + satisfied.add(offered_challb) return path @@ -415,11 +416,12 @@ def mutually_exclusive(obj1, obj2, groups, different=False): return True -def is_preferred(offered_chall, satisfied, +def is_preferred(offered_challb, satisfied, exclusive_groups=constants.EXCLUSIVE_CHALLENGES): """Return whether or not the challenge is preferred in path.""" - for chall in satisfied: + for challb in satisfied: if not mutually_exclusive( - offered_chall, chall, exclusive_groups, different=True): + offered_challb.chall, challb.chall, exclusive_groups, + different=True): return False return True From 214c0e9355f1f0b234efed66eda835cdf9714e8f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 15 Apr 2015 16:53:39 -0700 Subject: [PATCH 10/40] towards accounts --- letsencrypt/client/account.py | 115 ++++++++++++++++++ letsencrypt/client/auth_handler.py | 6 +- letsencrypt/client/client.py | 98 +++------------ letsencrypt/client/configuration.py | 10 ++ letsencrypt/client/crypto_util.py | 64 ++++++++++ letsencrypt/client/display/ops.py | 20 +++ letsencrypt/client/network2.py | 13 ++ .../client/plugins/apache/configurator.py | 17 +-- letsencrypt/client/tests/account_test.py | 10 ++ letsencrypt/scripts/main.py | 7 +- setup.py | 1 + 11 files changed, 266 insertions(+), 95 deletions(-) create mode 100644 letsencrypt/client/account.py create mode 100644 letsencrypt/client/tests/account_test.py diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py new file mode 100644 index 000000000..75f9acabf --- /dev/null +++ b/letsencrypt/client/account.py @@ -0,0 +1,115 @@ +import json +import os +import sys + +import configobj +import zope.component + +from letsencrypt.acme import messages2 + +from letsencrypt.client import crypto_util +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util + +from letsencrypt.client.display import ops as display_ops + + +class Account(object): + """ACME protocol registration. + + :ivar config: Client configuration object + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar key: Account/Authorized Key + :type key: :class:`~letsencrypt.client.le_util.Key` + + :ivar str email: Client's email address + :ivar str phone: Client's phone number + + :ivar bool save: Whether or not to save the account information + + :ivar regr: Registration Resource + :type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource` + + """ + def __init__(self, config, key, email=None, phone=None, regr=None): + self.key = key + self.config = config + self.email = email + self.phone = phone + + self.regr = regr + + def save(self): + # account_dir = le_util.make_or_verify_dir( + # os.path.join(self.config.config_dir, "accounts")) + # account_key_dir = le_util.make_or_verify_dir( + # os.path.join(account_dir, "keys"), 0o700) + + acc_config = configobj.ConfigObj() + # acc_config.filename = os.path.join( + # account_dir, self._get_config_filename()) + acc_config.filename = sys.stdout + + acc_config.initial_comment = [ + "Account information for %s under %s" % ( + self._get_config_filename(self.email), self.config.server)] + acc_config["key"] = self.key.path + acc_config["phone"] = self.phone + + regr_json = self.regr.to_json() + regr_dict = json.loads(regr_json) + + acc_config["regr"] = regr_dict + acc_config.write() + + @classmethod + def _get_config_filename(self, email): + return email if email is not None else "default" + + @classmethod + def from_existing_account(cls, config, email=None): + accounts_dir = os.path.join( + config.config_dir, "accounts", config.server) + config_fp = os.path.join(accounts_dir, cls._get_config_filename(email)) + return cls._from_config_fp(config, config_fp) + + @classmethod + def _from_config_fp(cls, config, config_fp): + try: + acc_config = configobj.ConfigObj( + infile=config_fp, file_error=True, create_empty=False) + except IOError: + raise errors.LetsEncryptClientError( + "Account for %s does not exist" % os.path.basename(config_fp)) + json_regr = json.dumps(acc_config["regr"]) + return cls(config, acc_config["key"], acc_config["email"], + acc_config["phone"], + messages2.RegistrationResource.from_json(json_regr)) + + @classmethod + def choose_account(cls, config): + """Choose one of the available accounts.""" + accounts = [] + accounts_dir = os.path.join(config.config_dir, "accounts") + filenames = os.listdir(accounts_dir) + for name in filenames: + # Not some directory ie. keys + config_fp = os.path.join(accounts_dir, name) + if os.path.isfile(config_fp): + accounts.append(cls._from_config_fp(config, config_fp)) + + if len(accounts) == 1: + return accounts[0] + elif len(accounts) > 1: + return display_ops.choose_account(accounts) + else: + return None + + @classmethod + def from_prompts(cls, config): + email = zope.component.getUtility(interfaces.IDisplay).input( + "Enter email address") + key_dir = os.path.join(config.config_dir, "accounts", config.server, "keys") + key = crypto_util.init_save_key(2048, config.accounts_dir, email) + return cls(config, email, key) \ No newline at end of file diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 09c731cf0..72af44526 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -172,8 +172,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes raise errors.AuthorizationError( "Failed Authorization procedure for %s" % domain) - self._cleanup_challenges(comp_challs) - self._cleanup_challenges(failed_challs) + self._cleanup_challenges(comp_challs+failed_challs) dom_to_check -= comp_domains comp_domains.clear() @@ -191,6 +190,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # challenges will be determined here... for achall in achalls: status = self._get_chall_status(self.authzr[domain], achall) + print "Status:", status # This does nothing for challenges that have yet to be decided yet. if status == messages2.STATUS_VALID: completed.append(achall) @@ -209,6 +209,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes for authzr_chall in authzr: if type(authzr_chall) is type(chall): return chall.status + raise errors.AuthorizationError( + "Target challenge not found in authorization resource") def _get_chall_pref(self, domain): """Return list of challenge preferences. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 67d89cff9..a89397046 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -4,6 +4,7 @@ import os import sys import M2Crypto +import zope.component from letsencrypt.acme import jose from letsencrypt.acme.jose import jwk @@ -12,6 +13,7 @@ from letsencrypt.client import auth_handler from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network2 from letsencrypt.client import reverter @@ -31,8 +33,8 @@ class Client(object): :ivar authkey: Authorization Key :type authkey: :class:`letsencrypt.client.le_util.Key` - :ivar reg: Registration Resource - :type reg: :class:`letsencrypt.acme.messages2.RegistrationResource` + :ivar account: Account object used for registration + :type account: :class:`letsencrypt.client.registration.Registration` :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a @@ -58,7 +60,7 @@ class Client(object): """ self.authkey = authkey - self.regr = None + self.account = None self.installer = installer # TODO: Allow for other alg types besides RS256 @@ -75,34 +77,19 @@ class Client(object): else: self.auth_handler = None - def register(self, email=None, phone=None): + def register(self, network, store=True): """New Registration with the ACME server. - :param str email: User's email address - :param str phone: User's phone number + :param bool store: Whether to store the registration information """ - # TODO: properly format/scrub phone number - details = ( - "mailto:" + email if email is not None else None, - "tel:" + phone if phone is not None else None - ) - contact_tuple = tuple(detail for detail in details if detail is not None) - - # TODO: Replace with real info once through testing. - if not contact_tuple: - contact_tuple = ("mailto:letsencrypt-client@letsencrypt.org", - "tel:+12025551212") - self.regr = self.network.register(contact=contact_tuple) - - # If terms of service exist... we need to sign it. - # TODO: Replace the `preview EULA` with this... - if self.regr.terms_of_service: - self.network.agree_to_tos(self.regr) - - def set_regr(self, regr): - """Set a preexisting registration resource.""" - self.regr = regr + self.account = self.network.register_from_account(self.account) + if self.account.regr.terms_of_service or self.config.tos: + agree = zope.component.getUtility(interfaces.IDisplay).yesno( + self.account.regr.terms_of_service, "Agree", "Cancel") + if agree: + self.account.regr = self.network.agree_to_tos(self.account.regr) + # TODO: Handle case where user doesn't agree def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. @@ -141,7 +128,8 @@ class Client(object): # Create CSR from names if csr is None: - csr = init_csr(self.authkey, domains, self.config.cert_dir) + csr = crypto_util.init_save_csr( + self.authkey, domains, self.config.cert_dir) # Retrieve certificate certr = self.network.request_issuance( @@ -323,60 +311,6 @@ def validate_key_csr(privkey, csr=None): "The key and CSR do not match") -def init_key(key_size, key_dir): - """Initializes privkey. - - Inits key and CSR using provided files or generating new files - if necessary. Both will be saved in PEM format on the - filesystem. The CSR is placed into DER format to allow - the namedtuple to easily work with the protocol. - - :param str key_dir: Key save directory. - - """ - try: - key_pem = crypto_util.make_key(key_size) - except ValueError as err: - logging.fatal(str(err)) - sys.exit(1) - - # Save file - le_util.make_or_verify_dir(key_dir, 0o700) - key_f, key_filename = le_util.unique_file( - os.path.join(key_dir, "key-letsencrypt.pem"), 0o600) - key_f.write(key_pem) - key_f.close() - - logging.info("Generating key (%d bits): %s", key_size, key_filename) - - return le_util.Key(key_filename, key_pem) - - -def init_csr(privkey, names, cert_dir): - """Initialize a CSR with the given private key. - - :param privkey: Key to include in the CSR - :type privkey: :class:`letsencrypt.client.le_util.Key` - - :param set names: `str` names to include in the CSR - - :param str cert_dir: Certificate save directory. - - """ - csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) - - # Save CSR - le_util.make_or_verify_dir(cert_dir, 0o755) - csr_f, csr_filename = le_util.unique_file( - os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) - csr_f.write(csr_pem) - csr_f.close() - - logging.info("Creating CSR: %s", csr_filename) - - return le_util.CSR(csr_filename, csr_der, "der") - - def list_available_authenticators(avail_auths): """Return a pretty-printed list of authenticators. diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index 87502ed63..7c5aecbcc 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -48,6 +48,16 @@ class NamespaceConfig(object): self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, self.namespace.server.partition(":")[0]) + @property + def accounts_dir(self): #pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, "accounts", self.namespace.server) + + @property + def account_keys_dir(self): #pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, "accounts", + self.namespace.server, "keys") + # TODO: This should probably include the server name @property def rec_token_dir(self): # pylint: disable=missing-docstring diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index e3d0d1c4d..e4b4311b5 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -4,6 +4,8 @@ is capable of handling the signatures. """ +import logging +import os import time import Crypto.Hash.SHA256 @@ -12,7 +14,69 @@ import Crypto.Signature.PKCS1_v1_5 import M2Crypto +from letsencrypt.client import le_util + +# High level functions +def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): + """Initializes and saves a privkey. + + Inits key and saves it in PEM format on the filesystem. + + .. note:: keyname is the attempted filename, it may be different if a file + already exists at the path. + + :param int key_size: RSA key size in bits + :param str key_dir: Key save directory. + :param str keyname: Filename of key + + :raises ValueError: If unable to generate the key given key_size. + + """ + try: + key_pem = make_key(key_size) + except ValueError as err: + logging.fatal(str(err)) + raise err + + # Save file + le_util.make_or_verify_dir(key_dir, 0o700) + key_f, key_path = le_util.unique_file( + os.path.join(key_dir, keyname), 0o600) + key_f.write(key_pem) + key_f.close() + + logging.info("Generating key (%d bits): %s", key_size, key_path) + + return le_util.Key(key_path, key_pem) + + +def init_save_csr(privkey, names, cert_dir): + """Initialize a CSR with the given private key. + + :param privkey: Key to include in the CSR + :type privkey: :class:`letsencrypt.client.le_util.Key` + + :param set names: `str` names to include in the CSR + + :param str cert_dir: Certificate save directory. + + """ + csr_pem, csr_der = make_csr(privkey.pem, names) + + # Save CSR + le_util.make_or_verify_dir(cert_dir, 0o755) + csr_f, csr_filename = le_util.unique_file( + os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) + csr_f.write(csr_pem) + csr_f.close() + + logging.info("Creating CSR: %s", csr_filename) + + return le_util.CSR(csr_filename, csr_der, "der") + + +# Lower level functions def make_csr(key_str, domains): """Generate a CSR. diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 1cffe2846..db4b4a4e9 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -42,6 +42,26 @@ def choose_authenticator(auths, errs): else: return +def choose_account(accounts): + """Choose an account. + + :param list accounts: where each is of type + :class:`~letsencrypt.client.account.Account` + + """ + # Note this will get more complicated once we start recording authorizations + + labels = [ + "%s | %s" % (acc.email.ljust(display_util.WIDTH - 39), acc.phone) + for acc in accounts + ] + + code, index = util(interfaces.IDisplay).menu( + "Please choose an account", labels) + if code == display_util.OK: + return accounts[index] + else: + return None def choose_names(installer): """Display screen to select domains to validate. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 7a50a40bf..011710dbe 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -186,6 +186,19 @@ class Network(object): return regr + def register_from_account(self, account): + # TODO: properly format/scrub phone number and email + details = ( + "mailto:" + self.email if self.email is not None else None, + "tel:" + self.phone if self.phone is not None else None + ) + + contact_tuple = tuple(det for det in details if det is not None) + + account.regr = self.register(contact=contact_tuple) + + return account + def update_registration(self, regr): """Update registration. diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index e6104a559..f3b952915 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -1006,15 +1006,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): apache_dvsni.add_chall(achall, i) sni_response = apache_dvsni.perform() - # Must restart in order to activate the challenges. - # Handled here because we may be able to load up other challenge types - self.restart() + if sni_response: + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge types + self.restart() - # Go through all of the challenges and assign them to the proper place - # in the responses return value. All responses must be in the same order - # as the original challenges. - for i, resp in enumerate(sni_response): - responses[apache_dvsni.indices[i]] = resp + # Go through all of the challenges and assign them to the proper + # place in the responses return value. All responses must be in the + # same order as the original challenges. + for i, resp in enumerate(sni_response): + responses[apache_dvsni.indices[i]] = resp return responses diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py new file mode 100644 index 000000000..5d812fdd8 --- /dev/null +++ b/letsencrypt/client/tests/account_test.py @@ -0,0 +1,10 @@ +import mock + +from letsencrypt.client import account +from letsencrypt.client import configuration + + +mock_config = mock.MagicMock(spec=configuration.NamespaceConfig) +acc = account.Account.from_prompts(mock_config) + +acc.save() \ No newline at end of file diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index e7a2674e9..225154cba 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -18,6 +18,7 @@ import letsencrypt from letsencrypt.client import configuration from letsencrypt.client import client +from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util @@ -93,7 +94,7 @@ def create_parser(): add("--no-confirm", dest="no_confirm", action="store_true", help="Turn off confirmation screens, currently used for --revoke") - add("-e", "--agree-tos", dest="eula", action="store_true", + add("-e", "--agree-tos", dest="tos", action="store_true", help="Skip the end user license agreement screen.") add("-t", "--text", dest="use_curses", action="store_false", help="Use the text output instead of the curses UI.") @@ -163,7 +164,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements client.rollback(args.rollback, config) sys.exit() - if not args.eula: + if not args.tos: display_eula() all_auths = init_auths(config) @@ -195,7 +196,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # Prepare for init of Client if args.authkey is None: - authkey = client.init_key(args.rsa_key_size, config.key_dir) + authkey = crypto_util.init_save_key(args.rsa_key_size, config.key_dir) else: authkey = le_util.Key(args.authkey[0], args.authkey[1]) diff --git a/setup.py b/setup.py index c399179e4..474c1c448 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', 'ConfArgParse', + 'configobj', 'jsonschema', 'mock', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) From 1e97c0c598490fb34df6664bcd5a15d2436a0ce0 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 16 Apr 2015 23:18:26 -0700 Subject: [PATCH 11/40] Add accounts and tests --- letsencrypt/client/account.py | 122 +++++++++++++----- letsencrypt/client/display/ops.py | 8 +- letsencrypt/client/tests/account_test.py | 128 ++++++++++++++++++- letsencrypt/client/tests/display/ops_test.py | 46 ++++++- 4 files changed, 268 insertions(+), 36 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index 75f9acabf..e25a19d51 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -1,6 +1,4 @@ -import json import os -import sys import configobj import zope.component @@ -13,6 +11,7 @@ from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client.display import ops as display_ops +from letsencrypt.client.display import util as display_util class Account(object): @@ -26,51 +25,85 @@ class Account(object): :ivar str email: Client's email address :ivar str phone: Client's phone number - :ivar bool save: Whether or not to save the account information - :ivar regr: Registration Resource :type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource` """ + + # Just make sure we don't get pwned + # Make sure that it also doesn't start with a period or have two consecutive + # periods <- this needs to be done in addition to the regex + EMAIL_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+" + def __init__(self, config, key, email=None, phone=None, regr=None): self.key = key self.config = config - self.email = email + if email is not None: + self.email = self.scrub_email(email) + else: + self.email = None self.phone = phone self.regr = regr + @property + def uri(self): + """URI link for new registrations.""" + if self.regr is not None: + return self.regr.uri + + @property + def new_authzr_uri(self): # pylint: disable=missing-docstring + if self.regr is not None: + return self.regr.new_authzr_uri + + @property + def terms_of_service(self): # pylint: disable=missing-docstring + if self.regr is not None: + return self.regr.terms_of_service + + @property + def recovery_token(self): # pylint: disable=missing-docstring + if self.regr is not None and self.regr.body is not None: + return self.regr.body.recovery_token + def save(self): - # account_dir = le_util.make_or_verify_dir( - # os.path.join(self.config.config_dir, "accounts")) - # account_key_dir = le_util.make_or_verify_dir( - # os.path.join(account_dir, "keys"), 0o700) + """Save account to disk.""" + le_util.make_or_verify_dir(self.accounts_dir) acc_config = configobj.ConfigObj() - # acc_config.filename = os.path.join( - # account_dir, self._get_config_filename()) - acc_config.filename = sys.stdout + acc_config.filename = os.path.join( + self.config.accounts_dir, self._get_config_filename(self.email)) acc_config.initial_comment = [ "Account information for %s under %s" % ( self._get_config_filename(self.email), self.config.server)] - acc_config["key"] = self.key.path + + acc_config["key"] = self.key.file acc_config["phone"] = self.phone - regr_json = self.regr.to_json() - regr_dict = json.loads(regr_json) + if self.regr is not None: + acc_config["RegistrationResource"] = {} + acc_config["RegistrationResource"]["uri"] = self.uri + acc_config["RegistrationResource"]["new_authzr_uri"] = ( + self.new_authzr_uri) + acc_config["RegistrationResource"]["terms_of_service"] = ( + self.terms_of_service) + + regr_dict = self.regr.body.to_json() + acc_config["RegistrationResource"]["body"] = regr_dict - acc_config["regr"] = regr_dict acc_config.write() @classmethod - def _get_config_filename(self, email): + def _get_config_filename(cls, email): return email if email is not None else "default" @classmethod def from_existing_account(cls, config, email=None): + """Populate an account from an existing email.""" accounts_dir = os.path.join( - config.config_dir, "accounts", config.server) + config.accounts_dir) config_fp = os.path.join(accounts_dir, cls._get_config_filename(email)) return cls._from_config_fp(config, config_fp) @@ -82,20 +115,36 @@ class Account(object): except IOError: raise errors.LetsEncryptClientError( "Account for %s does not exist" % os.path.basename(config_fp)) - json_regr = json.dumps(acc_config["regr"]) - return cls(config, acc_config["key"], acc_config["email"], - acc_config["phone"], - messages2.RegistrationResource.from_json(json_regr)) + + if os.path.basename(config_fp) != "default": + email = os.path.basename(config_fp) + else: + email = None + phone = acc_config["phone"] if acc_config["phone"] != "None" else None + + with open(acc_config["key"]) as key_file: + key = le_util.Key(acc_config["key"], key_file.read()) + + if "RegistrationResource" in acc_config: + acc_config_rr = acc_config["RegistrationResource"] + regr = messages2.RegistrationResource( + uri=acc_config_rr["uri"], + new_authzr_uri=acc_config_rr["new_authzr_uri"], + terms_of_service=acc_config_rr["terms_of_service"], + body=messages2.Registration.from_json(acc_config_rr["body"])) + else: + regr = None + + return cls(config, key, email, phone, regr) @classmethod def choose_account(cls, config): """Choose one of the available accounts.""" accounts = [] - accounts_dir = os.path.join(config.config_dir, "accounts") - filenames = os.listdir(accounts_dir) + filenames = os.listdir(config.accounts_dir) for name in filenames: # Not some directory ie. keys - config_fp = os.path.join(accounts_dir, name) + config_fp = os.path.join(config.accounts_dir, name) if os.path.isfile(config_fp): accounts.append(cls._from_config_fp(config, config_fp)) @@ -108,8 +157,23 @@ class Account(object): @classmethod def from_prompts(cls, config): - email = zope.component.getUtility(interfaces.IDisplay).input( + """Generate an account from prompted user input.""" + code, email = zope.component.getUtility(interfaces.IDisplay).input( "Enter email address") - key_dir = os.path.join(config.config_dir, "accounts", config.server, "keys") - key = crypto_util.init_save_key(2048, config.accounts_dir, email) - return cls(config, email, key) \ No newline at end of file + if code == display_util.OK: + email = email if email != "" else None + + print config.account_keys_dir + le_util.make_or_verify_dir( + config.account_keys_dir, 0o700, os.geteuid()) + key = crypto_util.init_save_key( + 2048, config.account_keys_dir, email) + return cls(config, key, email) + + return None + + @classmethod + def scrub_email(cls, email): + """Scrub email address before using it.""" + # TODO: Fill in + return email diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index db4b4a4e9..d396e1641 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -42,17 +42,18 @@ def choose_authenticator(auths, errs): else: return + def choose_account(accounts): """Choose an account. - :param list accounts: where each is of type + :param list accounts: Containing at least one :class:`~letsencrypt.client.account.Account` """ # Note this will get more complicated once we start recording authorizations - labels = [ - "%s | %s" % (acc.email.ljust(display_util.WIDTH - 39), acc.phone) + "%s | %s" % (acc.email.ljust(display_util.WIDTH - 39), + acc.phone if acc.phone is not None else "") for acc in accounts ] @@ -63,6 +64,7 @@ def choose_account(accounts): else: return None + def choose_names(installer): """Display screen to select domains to validate. diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 5d812fdd8..929b8dc32 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -1,10 +1,132 @@ import mock +import os +import pkg_resources +import shutil +import sys +import tempfile +import unittest + +import zope.component + +from letsencrypt.acme import messages2 from letsencrypt.client import account from letsencrypt.client import configuration +from letsencrypt.client import le_util + +from letsencrypt.client.display import util as display_util -mock_config = mock.MagicMock(spec=configuration.NamespaceConfig) -acc = account.Account.from_prompts(mock_config) +class AccountTest(unittest.TestCase): + """Tests letsencrypt.client.account.Account.""" -acc.save() \ No newline at end of file + def setUp(self): + self.accounts_dir = tempfile.mkdtemp("accounts") + self.account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(self.account_keys_dir, 0o700) + + self.config = mock.MagicMock( + spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, + account_keys_dir=self.account_keys_dir, + server="letsencrypt-demo.org") + + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", "testdata/rsa256_key.pem") + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/rsa256_key.pem") + + self.key = le_util.Key(rsa256_file, rsa256_pem) + self.email = "client@letsencrypt.org" + self.regr = messages2.RegistrationResource( + uri="uri", + new_authzr_uri="new_authzr_uri", + terms_of_service="terms_of_service", + body=messages2.Registration( + recovery_token="recovery_token", agreement="agreement") + ) + + self.test_account = account.Account( + self.config, self.key, self.email, None, self.regr) + + def tearDown(self): + shutil.rmtree(self.accounts_dir) + + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") + def test_prompts(self, mock_key, mock_util): + displayer = display_util.FileDisplay(sys.stdout) + zope.component.provideUtility(displayer) + + mock_util().input.return_value = (display_util.OK, self.email) + + mock_key.return_value = self.key + acc = account.Account.from_prompts(self.config) + + self.assertEqual(acc.email, self.email) + self.assertEqual(acc.key, self.key) + self.assertEqual(acc.config, self.config) + + def test_save(self): + self.test_account.save() + self._read_out_config(self.email) + + def test_save_from_existing_account(self): + self.test_account.save() + acc = account.Account.from_existing_account(self.config, self.email) + + self.assertEqual(acc.key, self.test_account.key) + self.assertEqual(acc.email, self.test_account.email) + self.assertEqual(acc.phone, self.test_account.phone) + self.assertEqual(acc.regr, self.test_account.regr) + + def test_properties(self): + self.assertEqual(self.test_account.uri, "uri") + self.assertEqual(self.test_account.new_authzr_uri, "new_authzr_uri") + self.assertEqual(self.test_account.terms_of_service, "terms_of_service") + self.assertEqual(self.test_account.recovery_token, "recovery_token") + + def test_partial_properties(self): + partial = account.Account(self.config, self.key) + + self.assertTrue(partial.uri is None) + self.assertTrue(partial.new_authzr_uri is None) + self.assertTrue(partial.terms_of_service is None) + self.assertTrue(partial.recovery_token is None) + + + def test_partial_account_default(self): + partial = account.Account(self.config, self.key) + partial.save() + + acc = account.Account.from_existing_account(self.config) + + self.assertEqual(partial.key, acc.key) + self.assertEqual(partial.email, acc.email) + self.assertEqual(partial.phone, acc.phone) + self.assertEqual(partial.regr, acc.regr) + + @mock.patch("letsencrypt.client.account.display_ops.choose_account") + def test_choose_account(self, mock_op): + mock_op.return_value = self.test_account + + # Test 0 + self.assertTrue(account.Account.choose_account(self.config) is None) + + # Test 1 + self.test_account.save() + acc = account.Account.choose_account(self.config) + self.assertEqual(acc.email, self.test_account.email) + + # Test multiple + self.assertFalse(mock_op.called) + acc2 = account.Account(self.config, self.key) + acc2.save() + test_acc = account.Account.choose_account(self.config) + self.assertTrue(mock_op.called) + self.assertTrue(test_acc.email, self.test_account.email) + + def _read_out_config(self, filep): + print open(os.path.join(self.accounts_dir, filep)).read() + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 11edfe4e3..8c3f59939 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -1,10 +1,13 @@ """Test letsencrypt.client.display.ops.""" +import os import sys +import tempfile import unittest import mock import zope.component +from letsencrypt.client import le_util from letsencrypt.client.display import util as display_util @@ -50,10 +53,51 @@ class ChooseAuthenticatorTest(unittest.TestCase): @mock.patch("letsencrypt.client.display.ops.util") def test_no_choice(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertTrue(self._call(self.auths, {}) is None) +class ChooseAccountTest(unittest.TestCase): + """Test choose_account.""" + def setUp(self): + from letsencrypt.client import account + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + self.accounts_dir = tempfile.mkdtemp("accounts") + self.account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(self.account_keys_dir, 0o700) + + self.config = mock.MagicMock( + accounts_dir=self.accounts_dir, + account_keys_dir=self.account_keys_dir, + server="letsencrypt-demo.org") + self.key = le_util.Key("keypath", "pem") + + self.acc1 = account.Account(self.config, self.key, "email1") + self.acc2 = account.Account(self.config, self.key, "email2", "phone") + self.acc1.save() + self.acc2.save() + + @classmethod + def _call(cls, accounts): + from letsencrypt.client.display import ops + return ops.choose_account(accounts) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_one(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 0) + self.assertEqual(self._call([self.acc1]), self.acc1) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_two(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 1) + self.assertEqual(self._call([self.acc1, self.acc2]), self.acc2) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_cancel(self, mock_util): + mock_util().menu.return_value = (display_util.CANCEL, 1) + self.assertTrue(self._call([self.acc1, self.acc2]) is None) + + class GenHttpsNamesTest(unittest.TestCase): """Test _gen_https_names.""" def setUp(self): From 3d9d0627d7b389fe2dde121a987c6da2af2e49a1 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 17 Apr 2015 00:57:51 -0700 Subject: [PATCH 12/40] add filename scrub --- letsencrypt/client/account.py | 27 ++++++++++++-------- letsencrypt/client/tests/account_test.py | 32 ++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index e25a19d51..ab7af3652 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -1,4 +1,5 @@ import os +import re import configobj import zope.component @@ -33,13 +34,15 @@ class Account(object): # Just make sure we don't get pwned # Make sure that it also doesn't start with a period or have two consecutive # periods <- this needs to be done in addition to the regex - EMAIL_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+" + EMAIL_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$" def __init__(self, config, key, email=None, phone=None, regr=None): + le_util.make_or_verify_dir( + config.accounts_dir, 0o700, os.geteuid()) self.key = key self.config = config - if email is not None: - self.email = self.scrub_email(email) + if email is not None and self.safe_email(email): + self.email = email else: self.email = None self.phone = phone @@ -69,7 +72,8 @@ class Account(object): def save(self): """Save account to disk.""" - le_util.make_or_verify_dir(self.accounts_dir) + le_util.make_or_verify_dir( + self.config.accounts_dir, 0o700, os.geteuid()) acc_config = configobj.ConfigObj() acc_config.filename = os.path.join( @@ -102,9 +106,9 @@ class Account(object): @classmethod def from_existing_account(cls, config, email=None): """Populate an account from an existing email.""" - accounts_dir = os.path.join( - config.accounts_dir) - config_fp = os.path.join(accounts_dir, cls._get_config_filename(email)) + + config_fp = os.path.join( + config.accounts_dir, cls._get_config_filename(email)) return cls._from_config_fp(config, config_fp) @classmethod @@ -167,13 +171,14 @@ class Account(object): le_util.make_or_verify_dir( config.account_keys_dir, 0o700, os.geteuid()) key = crypto_util.init_save_key( - 2048, config.account_keys_dir, email) + config.rsa_key_size, config.account_keys_dir, email) return cls(config, key, email) return None @classmethod - def scrub_email(cls, email): + def safe_email(cls, email): """Scrub email address before using it.""" - # TODO: Fill in - return email + if re.match(cls.EMAIL_REGEX, email): + return bool(not email.startswith(".") and ".." not in email) + return False diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 929b8dc32..18f87270a 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -27,7 +27,7 @@ class AccountTest(unittest.TestCase): self.config = mock.MagicMock( spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, - account_keys_dir=self.account_keys_dir, + account_keys_dir=self.account_keys_dir, rsa_key_size=2048, server="letsencrypt-demo.org") rsa256_file = pkg_resources.resource_filename( @@ -128,5 +128,33 @@ class AccountTest(unittest.TestCase): def _read_out_config(self, filep): print open(os.path.join(self.accounts_dir, filep)).read() + +class SafeEmailTest(unittest.TestCase): + """Test safe_email.""" + + @classmethod + def _call(cls, addr): + from letsencrypt.client.account import Account + return Account.safe_email(addr) + + def test_valid_emails(self): + addrs = [ + "letsencrypt@letsencrypt.org", + "tbd.ade@gmail.com", + "abc_def.jdk@hotmail.museum" + ] + for addr in addrs: + self.assertTrue(addr, "%s failed." % addr) + + def test_invalid_emails(self): + addrs = [ + "letsencrypt@letsencrypt..org", + ".tbd.ade@gmail.com", + "~/abc_def.jdk@hotmail.museum" + ] + for addr in addrs: + self.assertTrue(addr, "%s failed." % addr) + + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From ab616a598fc9684ef47ed9b2745e2d9f56a3d0db Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 17 Apr 2015 03:40:22 -0700 Subject: [PATCH 13/40] account integration --- letsencrypt/client/account.py | 38 +++++++---- letsencrypt/client/auth_handler.py | 12 ++-- letsencrypt/client/client.py | 71 +++++++++++++------- letsencrypt/client/network2.py | 14 +++- letsencrypt/client/tests/account_test.py | 25 +++---- letsencrypt/client/tests/client_test.py | 46 +++++++++++++ letsencrypt/client/tests/display/ops_test.py | 6 +- letsencrypt/scripts/main.py | 12 ++-- 8 files changed, 154 insertions(+), 70 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index ab7af3652..6046ae027 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -1,3 +1,4 @@ +import logging import os import re @@ -11,7 +12,6 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util -from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import util as display_util @@ -142,32 +142,43 @@ class Account(object): return cls(config, key, email, phone, regr) @classmethod - def choose_account(cls, config): - """Choose one of the available accounts.""" + def get_accounts(cls, config): + """Return all current accounts. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ + try: + filenames = os.listdir(config.accounts_dir) + except OSError: + return [] + accounts = [] - filenames = os.listdir(config.accounts_dir) for name in filenames: # Not some directory ie. keys config_fp = os.path.join(config.accounts_dir, name) if os.path.isfile(config_fp): accounts.append(cls._from_config_fp(config, config_fp)) - if len(accounts) == 1: - return accounts[0] - elif len(accounts) > 1: - return display_ops.choose_account(accounts) - else: - return None + return accounts @classmethod def from_prompts(cls, config): - """Generate an account from prompted user input.""" + """Generate an account from prompted user input. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :returns: Account or None + :rtype: :class:`letsencrypt.client.account.Account` + + """ code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address") + "Enter email address (optional)") if code == display_util.OK: email = email if email != "" else None - print config.account_keys_dir le_util.make_or_verify_dir( config.account_keys_dir, 0o700, os.geteuid()) key = crypto_util.init_save_key( @@ -181,4 +192,5 @@ class Account(object): """Scrub email address before using it.""" if re.match(cls.EMAIL_REGEX, email): return bool(not email.startswith(".") and ".." not in email) + logging.warn("Invalid email address: using default address.") return False diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 72af44526..24872b43b 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -26,8 +26,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes messages :type network: :class:`letsencrypt.client.network2.Network` - :ivar authkey: Authorized Keys for domains. - :type authkey: :class:`letsencrypt.client.le_util.Key` + :ivar account: Client's Account + :type account: :class:`letsencrypt.client.account.Account` :ivar dict authzr: ACME Authorization Resource dict where keys are domains. :ivar list dv_c: DV challenges in the form of @@ -36,12 +36,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge` """ - def __init__(self, dv_auth, cont_auth, network, authkey): + def __init__(self, dv_auth, cont_auth, network, account): self.dv_auth = dv_auth self.cont_auth = cont_auth self.network = network - self.authkey = authkey + self.account = account self.authzr = dict() # List must be used to keep responses straight. @@ -275,11 +275,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes if isinstance(chall, challenges.DVSNI): logging.info(" DVSNI challenge for %s.", domain) achall = achallenges.DVSNI( - challb=challb, domain=domain, key=self.authkey) + challb=challb, domain=domain, key=self.account.key) elif isinstance(chall, challenges.SimpleHTTPS): logging.info(" SimpleHTTPS challenge for %s.", domain) achall = achallenges.SimpleHTTPS( - challb=challb, domain=domain, key=self.authkey) + challb=challb, domain=domain, key=self.account.key) elif isinstance(chall, challenges.DNS): logging.info(" DNS challenge for %s.", domain) achall = achallenges.DNS(challb=challb, domain=domain) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a89397046..92914631c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,7 +1,6 @@ """ACME protocol client class and helper functions.""" import logging import os -import sys import M2Crypto import zope.component @@ -9,6 +8,7 @@ import zope.component from letsencrypt.acme import jose from letsencrypt.acme.jose import jwk +from letsencrypt.client import account from letsencrypt.client import auth_handler from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util @@ -30,11 +30,8 @@ class Client(object): :ivar network: Network object for sending and receiving messages :type network: :class:`letsencrypt.client.network2.Network` - :ivar authkey: Authorization Key - :type authkey: :class:`letsencrypt.client.le_util.Key` - :ivar account: Account object used for registration - :type account: :class:`letsencrypt.client.registration.Registration` + :type account: :class:`letsencrypt.client.account.Account` :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a @@ -49,7 +46,7 @@ class Client(object): """ - def __init__(self, config, authkey, dv_auth, installer): + def __init__(self, config, account, dv_auth, installer): """Initialize a client. :param dv_auth: IAuthenticator that can solve the @@ -59,37 +56,39 @@ class Client(object): :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.authkey = authkey - self.account = None + self.account = account + self.installer = installer # TODO: Allow for other alg types besides RS256 self.network = network2.Network( "https://%s/acme/new-reg" % config.server, - jwk.JWKRSA.load(authkey.pem)) + jwk.JWKRSA.load(account.key.pem)) self.config = config if dv_auth is not None: cont_auth = continuity_auth.ContinuityAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( - dv_auth, cont_auth, self.network, self.authkey) + dv_auth, cont_auth, self.network, self.account) else: self.auth_handler = None - def register(self, network, store=True): - """New Registration with the ACME server. - - :param bool store: Whether to store the registration information - - """ + def register(self, save=True): + """New Registration with the ACME server.""" self.account = self.network.register_from_account(self.account) - if self.account.regr.terms_of_service or self.config.tos: - agree = zope.component.getUtility(interfaces.IDisplay).yesno( - self.account.regr.terms_of_service, "Agree", "Cancel") + if self.account.terms_of_service: + if not self.config.tos: + agree = zope.component.getUtility(interfaces.IDisplay).yesno( + self.account.terms_of_service, "Agree", "Cancel") + else: + agree = True + if agree: self.account.regr = self.network.agree_to_tos(self.account.regr) - # TODO: Handle case where user doesn't agree + # TODO: Handle case where user doesn't agree + + self.account.save() def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. @@ -111,14 +110,14 @@ class Client(object): "not set.") logging.warning(msg) raise errors.LetsEncryptClientError(msg) - if self.regr is None: + if self.account.regr is None: raise errors.LetsEncryptClientError( "Please register with the ACME server first.") # Perform Challenges/Get Authorizations - if self.regr.new_authzr_uri: + if self.account.new_authzr_uri: authzr = self.auth_handler.get_authorizations( - domains, self.regr.new_authzr_uri) + domains, self.account.new_authzr_uri) # This isn't required to be in the registration resource... # and it isn't standardized... ugh - acme-spec #93 else: @@ -129,7 +128,7 @@ class Client(object): # Create CSR from names if csr is None: csr = crypto_util.init_save_csr( - self.authkey, domains, self.config.cert_dir) + self.account.key, domains, self.config.cert_dir) # Retrieve certificate certr = self.network.request_issuance( @@ -142,7 +141,7 @@ class Client(object): certr, self.config.cert_path, self.config.chain_path) revoker.Revoker.store_cert_key( - cert_file, self.authkey.file, self.config) + cert_file, self.account.key.file, self.config) return cert_file, chain_file @@ -379,6 +378,28 @@ def determine_authenticator(all_auths, config): return auth +def determine_account(config): + """Determine which account to use. + + Will create an account if necessary. + + :param config: Configuration object + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :returns: Account + :rtype: :class:`letsencrypt.client.account.Account` + + """ + accounts = account.Account.get_accounts(config) + + if len(accounts) == 1: + return accounts[0] + elif len(accounts) > 1: + return display_ops.choose_account(accounts) + + return account.Account.from_prompts(config) + + def determine_installer(config): """Returns a valid installer if one exists. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 011710dbe..e740b4240 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -187,10 +187,18 @@ class Network(object): return regr def register_from_account(self, account): - # TODO: properly format/scrub phone number and email + """Register with server. + + :param account: Account + :type account: :class:`letsencrypt.client.account.Account` + + :returns: Updated account + :rtype: :class:`letsencrypt.client.account.Account` + + """ details = ( - "mailto:" + self.email if self.email is not None else None, - "tel:" + self.phone if self.phone is not None else None + "mailto:" + account.email if account.email is not None else None, + "tel:" + account.phone if account.phone is not None else None ) contact_tuple = tuple(det for det in details if det is not None) diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 18f87270a..5baafe7d8 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -93,7 +93,6 @@ class AccountTest(unittest.TestCase): self.assertTrue(partial.terms_of_service is None) self.assertTrue(partial.recovery_token is None) - def test_partial_account_default(self): partial = account.Account(self.config, self.key) partial.save() @@ -105,25 +104,19 @@ class AccountTest(unittest.TestCase): self.assertEqual(partial.phone, acc.phone) self.assertEqual(partial.regr, acc.regr) - @mock.patch("letsencrypt.client.account.display_ops.choose_account") - def test_choose_account(self, mock_op): - mock_op.return_value = self.test_account + def test_get_accounts(self): + accs = account.Account.get_accounts(self.config) + self.assertFalse(accs) - # Test 0 - self.assertTrue(account.Account.choose_account(self.config) is None) - - # Test 1 self.test_account.save() - acc = account.Account.choose_account(self.config) - self.assertEqual(acc.email, self.test_account.email) + accs = account.Account.get_accounts(self.config) + self.assertEqual(len(accs), 1) + self.assertEqual(accs[0].email, self.test_account.email) - # Test multiple - self.assertFalse(mock_op.called) - acc2 = account.Account(self.config, self.key) + acc2 = account.Account(self.config, self.key, "testing_email@gmail.com") acc2.save() - test_acc = account.Account.choose_account(self.config) - self.assertTrue(mock_op.called) - self.assertTrue(test_acc.email, self.test_account.email) + accs = account.Account.get_accounts(self.config) + self.assertEqual(len(accs), 2) def _read_out_config(self, filep): print open(os.path.join(self.accounts_dir, filep)).read() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 63170b517..696a83f93 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -1,10 +1,56 @@ """letsencrypt.client.client.py tests.""" +import os import unittest +import shutil +import tempfile import mock +from letsencrypt.client import account from letsencrypt.client import configuration from letsencrypt.client import errors +from letsencrypt.client import le_util + + +class DetermineAccountTest(unittest.TestCase): + def setUp(self): + self.accounts_dir = tempfile.mkdtemp("accounts") + self.account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(self.account_keys_dir, 0o700) + + self.config = mock.MagicMock( + spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, + account_keys_dir=self.account_keys_dir, rsa_key_size=2048, + server="letsencrypt-demo.org") + + def tearDown(self): + shutil.rmtree(self.accounts_dir) + + @mock.patch("letsencrypt.client.client.account.Account.from_prompts") + @mock.patch("letsencrypt.client.client.display_ops.choose_account") + def determine_account(self, mock_op, mock_prompt): + from letsencrypt.client import client + + key = le_util.Key("file", "pem") + test_acc = account.Account(self.config, key, "email1@gmail.com") + mock_op.return_value = test_acc + + # Test 0 + mock_prompt.return_value = None + self.assertTrue(client.determine_account(self.config) is None) + + # Test 1 + test_acc.save() + acc = client.determine_account(self.config) + self.assertEqual(acc.email, test_acc.email) + + # Test multiple + self.assertFalse(mock_op.called) + acc2 = account.Account(self.config, self.key) + acc2.save() + chosen_acc = client.determine_account(self.config) + self.assertTrue(mock_op.called) + self.assertTrue(chosen_acc.email, test_acc.email) class DetermineAuthenticatorTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 8c3f59939..73b6ba430 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -72,8 +72,9 @@ class ChooseAccountTest(unittest.TestCase): server="letsencrypt-demo.org") self.key = le_util.Key("keypath", "pem") - self.acc1 = account.Account(self.config, self.key, "email1") - self.acc2 = account.Account(self.config, self.key, "email2", "phone") + self.acc1 = account.Account(self.config, self.key, "email1@g.com") + self.acc2 = account.Account( + self.config, self.key, "email2@g.com", "phone") self.acc1.save() self.acc2.save() @@ -84,6 +85,7 @@ class ChooseAccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.display.ops.util") def test_one(self, mock_util): + print self.acc1 mock_util().menu.return_value = (display_util.OK, 0) self.assertEqual(self._call([self.acc1]), self.acc1) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 225154cba..3e31480af 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -196,14 +196,16 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # Prepare for init of Client if args.authkey is None: - authkey = crypto_util.init_save_key(args.rsa_key_size, config.key_dir) + account = client.determine_account(config) else: - authkey = le_util.Key(args.authkey[0], args.authkey[1]) + # TODO: Figure out what to do with this + # le_util.Key(args.authkey[0], args.authkey[1]) + account = client.determine_account(config) - acme = client.Client(config, authkey, auth, installer) + acme = client.Client(config, account, auth, installer) # Validate the key and csr - client.validate_key_csr(authkey) + client.validate_key_csr(account.key) # This more closely mimics the capabilities of the CLI # It should be possible for reconfig only, install-only, no-install @@ -214,7 +216,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements acme.register() cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, authkey, cert_file, chain_file) + acme.deploy_certificate(doms, account.key, cert_file, chain_file) if installer is not None: acme.enhance_config(doms, args.redirect) From 495e1adaca91dd255b85989cbe821c34ff3e1e42 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 17 Apr 2015 15:09:19 -0700 Subject: [PATCH 14/40] Add Registration encoding/fix hashable JWKRSA --- letsencrypt/acme/jose/jwk.py | 7 +++-- letsencrypt/acme/jose/jwk_test.py | 16 ++++++----- letsencrypt/acme/messages2.py | 3 +- letsencrypt/acme/messages2_test.py | 45 ++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py index 1b7e00e56..2e70ac66b 100644 --- a/letsencrypt/acme/jose/jwk.py +++ b/letsencrypt/acme/jose/jwk.py @@ -126,9 +126,10 @@ class JWKRSA(JWK): @classmethod def fields_from_json(cls, jobj): - return cls(key=Crypto.PublicKey.RSA.construct( - (cls._decode_param(jobj['n']), - cls._decode_param(jobj['e'])))) + return cls(key=util.HashableRSAKey( + Crypto.PublicKey.RSA.construct( + (cls._decode_param(jobj['n']), + cls._decode_param(jobj['e']))))) def fields_to_json(self): return { diff --git a/letsencrypt/acme/jose/jwk_test.py b/letsencrypt/acme/jose/jwk_test.py index b75d3e1ce..7006f74a8 100644 --- a/letsencrypt/acme/jose/jwk_test.py +++ b/letsencrypt/acme/jose/jwk_test.py @@ -6,6 +6,7 @@ import unittest from Crypto.PublicKey import RSA from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import util RSA256_KEY = RSA.importKey(pkg_resources.resource_string( @@ -42,15 +43,15 @@ class JWKRSATest(unittest.TestCase): def setUp(self): from letsencrypt.acme.jose.jwk import JWKRSA - self.jwk256 = JWKRSA(key=RSA256_KEY.publickey()) - self.jwk256_private = JWKRSA(key=RSA256_KEY) + self.jwk256 = JWKRSA(key=util.HashableRSAKey(RSA256_KEY.publickey())) + self.jwk256_private = JWKRSA(key=util.HashableRSAKey(RSA256_KEY)) self.jwk256json = { 'kty': 'RSA', 'e': 'AQAB', 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', } - self.jwk512 = JWKRSA(key=RSA512_KEY.publickey()) + self.jwk512 = JWKRSA(key=util.HashableRSAKey(RSA512_KEY.publickey())) self.jwk512json = { 'kty': 'RSA', 'e': 'AQAB', @@ -68,10 +69,11 @@ class JWKRSATest(unittest.TestCase): def test_load(self): from letsencrypt.acme.jose.jwk import JWKRSA - self.assertEqual(JWKRSA(key=RSA256_KEY), JWKRSA.load( - pkg_resources.resource_string( - 'letsencrypt.client.tests', - os.path.join('testdata', 'rsa256_key.pem')))) + self.assertEqual( + JWKRSA(key=util.HashableRSAKey(RSA256_KEY)), JWKRSA.load( + pkg_resources.resource_string( + 'letsencrypt.client.tests', + os.path.join('testdata', 'rsa256_key.pem')))) def test_public(self): self.assertEqual(self.jwk256, self.jwk256_private.public()) diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index b04291af3..2fefefd11 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -136,7 +136,8 @@ class Registration(ResourceBody): # on new-reg key server ignores 'key' and populates it based on # JWS.signature.combined.jwk - key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + key = jose.Field('key', omitempty=True, + decoder=jose.JWK.from_json, encoder=jose.JWK.to_json) contact = jose.Field('contact', omitempty=True, default=()) recovery_token = jose.Field('recoveryToken', omitempty=True) agreement = jose.Field('agreement', omitempty=True) diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py index 614895b98..15447eacc 100644 --- a/letsencrypt/acme/messages2_test.py +++ b/letsencrypt/acme/messages2_test.py @@ -1,9 +1,12 @@ """Tests for letsencrypt.acme.messages2.""" import datetime +import os +import pkg_resources import unittest import mock import pytz +from Crypto.PublicKey import RSA from letsencrypt.acme import challenges from letsencrypt.acme import jose @@ -66,6 +69,48 @@ class ConstantTest(unittest.TestCase): self.assertEqual('MockConstant(b)', repr(self.const_b)) +class RegistrationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Registration.""" + + def setUp(self): + from letsencrypt.acme.messages2 import Registration + + rsa_key = RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', os.path.join( + 'testdata', 'rsa256_key.pem'))) + + self.key = jose.jwk.JWKRSA(key=jose.util.HashableRSAKey( + rsa_key.publickey())) + + self.contact = ("mailto:letsencrypt-client@letsencrypt.org",) + self.recovery_token = "XYZ" + self.agreement = "https://letsencrypt.org/terms" + self.reg = Registration( + key=self.key, contact=self.contact, + recovery_token=self.recovery_token, agreement=self.agreement) + + self.json_key = { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' + '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', + } + + self.json_reg = { + "contact": self.contact, + "recoveryToken": self.recovery_token, + "agreement": self.agreement, + "key": self.json_key, + } + + def test_to_json(self): + self.assertEqual(self.reg.to_json(), self.json_reg) + + def test_from_json(self): + from letsencrypt.acme.messages2 import Registration + + self.assertEqual(Registration.from_json(self.json_reg), self.reg) + class ChallengeResourceTest(unittest.TestCase): """Tests for letsencrypt.acme.messages2.ChallengeResource.""" From fcf4f69279e1b7a1f92ab703b8942fedbdaac5b2 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 17 Apr 2015 15:35:43 -0700 Subject: [PATCH 15/40] Cleanup RegistrationTest --- letsencrypt/acme/messages2_test.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py index 15447eacc..c2b3296e2 100644 --- a/letsencrypt/acme/messages2_test.py +++ b/letsencrypt/acme/messages2_test.py @@ -79,17 +79,18 @@ class RegistrationTest(unittest.TestCase): 'letsencrypt.client.tests', os.path.join( 'testdata', 'rsa256_key.pem'))) - self.key = jose.jwk.JWKRSA(key=jose.util.HashableRSAKey( + jwk_key = jose.jwk.JWKRSA(key=jose.util.HashableRSAKey( rsa_key.publickey())) - self.contact = ("mailto:letsencrypt-client@letsencrypt.org",) - self.recovery_token = "XYZ" - self.agreement = "https://letsencrypt.org/terms" - self.reg = Registration( - key=self.key, contact=self.contact, - recovery_token=self.recovery_token, agreement=self.agreement) + contact = ('mailto:letsencrypt-client@letsencrypt.org',) + recovery_token = 'XYZ' + agreement = 'https://letsencrypt.org/terms' - self.json_key = { + self.reg = Registration( + key=jwk_key, contact=contact, + recovery_token=recovery_token, agreement=agreement) + + self.json_jwk_key = { 'kty': 'RSA', 'e': 'AQAB', 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' @@ -97,10 +98,10 @@ class RegistrationTest(unittest.TestCase): } self.json_reg = { - "contact": self.contact, - "recoveryToken": self.recovery_token, - "agreement": self.agreement, - "key": self.json_key, + 'contact': contact, + 'recoveryToken': recovery_token, + 'agreement': agreement, + 'key': self.json_jwk_key, } def test_to_json(self): From 932edbaf75b9b350a29bf234596c39fcce4dc2e0 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 17 Apr 2015 16:45:10 -0700 Subject: [PATCH 16/40] Select all domains by default --- letsencrypt/client/display/util.py | 14 +++++++++----- letsencrypt/client/interfaces.py | 8 ++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index a55716a73..d34c6b46b 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -133,19 +133,21 @@ class NcursesDisplay(object): message, self.height, self.width, yes_label=yes_label, no_label=no_label) - def checklist(self, message, tags): + def checklist(self, message, tags, default_status=True): """Displays a checklist. :param message: Message to display before choices - :param list tags: where each is of type :class:`str` - len(tags) > 0 + :param list tags: where each is of type :class:`str` len(tags) > 0 + :param bool default_status: If True, items are in a selected state by + default. + :returns: tuple of the form (code, list_tags) where `code` - int display exit code `list_tags` - list of str tags selected by the user """ - choices = [(tag, "", False) for tag in tags] + choices = [(tag, "", default_status) for tag in tags] return self.dialog.checklist( message, width=self.width, height=self.height, choices=choices) @@ -257,11 +259,13 @@ class FileDisplay(object): ans.startswith(no_label[0].upper())): return False - def checklist(self, message, tags): + def checklist(self, message, tags, default_status=True): + # pylint: disable=unused-argument """Display a checklist. :param str message: Message to display to user :param list tags: `str` tags to select, len(tags) > 0 + :param bool default_status: Not used for FileDisplay :returns: tuple of (`code`, `tags`) where `code` - str display exit code diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 0f032a92e..8e9f7c453 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -281,13 +281,13 @@ class IDisplay(zope.interface.Interface): """ - def checklist(message, choices): + def checklist(message, tags, default_state): """Allow for multiple selections from a menu. :param str message: message to display to the user - - :param tags: tags - :type tags: :class:`list` of :class:`str` + :param list tags: where each is of type :class:`str` len(tags) > 0 + :param bool default_status: If True, items are in a selected state by + default. """ From 849415f71b82e021b33b6a910b891fcd52a88f54 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 19 Apr 2015 20:10:40 -0700 Subject: [PATCH 17/40] prep testing infrastructure --- letsencrypt/client/account.py | 7 +- letsencrypt/client/auth_handler.py | 9 +-- letsencrypt/client/client.py | 10 +-- letsencrypt/client/tests/acme_util.py | 62 ++++++++++++++-- letsencrypt/client/tests/auth_handler_test.py | 71 ++++--------------- 5 files changed, 83 insertions(+), 76 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index 6046ae027..ff4956364 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -58,7 +58,12 @@ class Account(object): @property def new_authzr_uri(self): # pylint: disable=missing-docstring if self.regr is not None: - return self.regr.new_authzr_uri + if self.regr.new_authzr_uri: + return self.regr.new_authzr_uri + else: + # Default: spec says they "may" provide the header + # ugh.. acme-spec #93 + return "https://%s/acme/new-authz" % self.config.server @property def terms_of_service(self): # pylint: disable=missing-docstring diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 24872b43b..c40d4057a 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -29,7 +29,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar account: Client's Account :type account: :class:`letsencrypt.client.account.Account` - :ivar dict authzr: ACME Authorization Resource dict where keys are domains. + :ivar dict authzr: ACME Authorization Resource dict where keys are domains + and values are :class:`letsencrypt.acme.messages2.AuthorizationResource` :ivar list dv_c: DV challenges in the form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge` :ivar list cont_c: Continuity challenges in the @@ -48,11 +49,10 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.dv_c = [] self.cont_c = [] - def get_authorizations(self, domains, new_authz_uri, best_effort=False): + def get_authorizations(self, domains, best_effort=False): """Retrieve all authorizations for challenges. :param set domains: Domains for authorization - :param str new_authz_uri: Location to get new authorization resources :param bool best_effort: Whether or not all authorizations are required (this is useful in renewal) @@ -66,7 +66,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ for domain in domains: self.authzr[domain] = self.network.request_domain_challenges( - domain, new_authz_uri) + domain, self.account.new_authzr_uri) self._choose_challenges(domains) # While there are still challenges remaining... @@ -80,6 +80,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes return self.authzr.values() def _choose_challenges(self, domains): + """Retrieve necessary challenges to satisfy server.""" logging.info("Performing the following challenges:") for dom in domains: path = gen_challenge_path( diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 92914631c..a6ce76432 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -115,15 +115,7 @@ class Client(object): "Please register with the ACME server first.") # Perform Challenges/Get Authorizations - if self.account.new_authzr_uri: - authzr = self.auth_handler.get_authorizations( - domains, self.account.new_authzr_uri) - # This isn't required to be in the registration resource... - # and it isn't standardized... ugh - acme-spec #93 - else: - authzr = self.auth_handler.get_authorizations( - domains, - "https://%s/acme/new-authz" % self.config.server) + authzr = self.auth_handler.get_authorizations(domains) # Create CSR from names if csr is None: diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 5a2e2b16f..8aa5f4cc8 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -1,4 +1,6 @@ """Class helps construct valid ACME messages for testing.""" +import datetime +import itertools import os import pkg_resources @@ -6,6 +8,7 @@ import Crypto.PublicKey.RSA from letsencrypt.acme import challenges from letsencrypt.acme import jose +from letsencrypt.acme import messages2 KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( @@ -52,13 +55,13 @@ CONT_CHALLENGES = [chall for chall in CHALLENGES if isinstance(chall, challenges.ContinuityChallenge)] -def gen_combos(challs): - """Generate natural combinations for challs.""" +def gen_combos(challbs): + """Generate natural combinations for challbs.""" dv_chall = [] cont_chall = [] - for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name - if isinstance(chall, challenges.DVChallenge): + for i, challb in enumerate(challbs): # pylint: disable=redefined-outer-name + if isinstance(challb.chall, challenges.DVChallenge): dv_chall.append(i) else: cont_chall.append(i) @@ -66,3 +69,54 @@ def gen_combos(challs): # Gen combos for 1 of each type, lowest index first (makes testing easier) return tuple((i, j) if i < j else (j, i) for i in dv_chall for j in cont_chall) + + +def chall_to_challb(chall, status): + """Return ChallengeBody from Challenge. + + :param str status: "valid", "invalid", "pending"... + + """ + kwargs = { + "uri": chall.typ+"_uri", + "status": messages2.Status(status), + } + + if status == "valid": + kwargs.update({"validated": datetime.datetime.now()}) + + return messages2.ChallengeBody(**kwargs) + + +def gen_authzr(authz_status, domain, challs, statuses, combos=True): + """Generate an authorization resource. + + :param str authz_status: "valid", "invalid", "pending"... + :param list challs: Challenge objects + :param list statuses: status of each challenge object e.g. "valid"... + :param bool combos: Whether or not to add combinations + + """ + challbs = [ + chall_to_challb(chall, status) + for chall, status in itertools.izip(challs, statuses) + ] + authz_kwargs = { + "identifier": messages2.Identifier( + type=messages2.IDENTIFIER_FQDN, value=domain), + "challenges": challbs, + } + if combos: + authz_kwargs.update({"combinations": gen_combos(challbs)}) + if authz_status == "valid": + now = datetime.datetime.now() + authz_kwargs.update({ + "status": messages2.Status(authz_status), + "expires": datetime.datetime(now.year, now.month+1, now.day), + }) + + return messages2.AuthorizationResource( + uri="https://trusted.ca/new-authz-resource", + new_cert_uri="https://trusted.ca/new-cert", + body=messages2.Authorization(**authz_kwargs) + ) \ No newline at end of file diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index b9508709d..76c30ed37 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -5,10 +5,12 @@ import unittest import mock from letsencrypt.acme import challenges -from letsencrypt.acme import messages +from letsencrypt.acme import messages2 +from letsencrypt.client import account from letsencrypt.client import achallenges from letsencrypt.client import errors +from letsencrypt.client import le_util from letsencrypt.client.tests import acme_util @@ -23,7 +25,7 @@ TRANSLATE = { } -class SatisfyChallengesTest(unittest.TestCase): +class SolveChallengesTest(unittest.TestCase): """verify_identities test.""" def setUp(self): @@ -39,8 +41,10 @@ class SatisfyChallengesTest(unittest.TestCase): self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp + self.account = account.Account(None, le_util.Key("filepath", "pem")) + self.handler = AuthHandler( - self.mock_dv_auth, self.mock_cont_auth, None) + self.mock_dv_auth, self.mock_cont_auth, None, account) logging.disable(logging.CRITICAL) @@ -48,22 +52,17 @@ class SatisfyChallengesTest(unittest.TestCase): logging.disable(logging.NOTSET) def test_name1_dvsni1(self): + # pylint: disable=protected-access dom = "0" - msg = messages.Challenge( - session_id=dom, nonce="nonce0", combinations=[], - challenges=[acme_util.DVSNI]) - self.handler.add_chall_msg(dom, msg, "dummy_key") + # Note: + self.handler.dv_c = [] + cont_resp, dv_resp = self.handler._solve_challenges() - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 1) self.assertEqual(len(self.handler.responses[dom]), 1) self.assertEqual("DVSNI0", self.handler.responses[dom][0]) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.cont_c), 1) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.cont_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c), 0) def test_name1_rectok1(self): dom = "0" @@ -292,7 +291,7 @@ class SatisfyChallengesTest(unittest.TestCase): for i in xrange(3): self.handler.add_chall_msg( str(i), - messages.Challenge( + messages2.Challenge( session_id=str(i), nonce="nonce%d" % i, challenges=acme_util.CHALLENGES, combinations=combos), "dummy_key") @@ -469,50 +468,6 @@ class GetAuthorizationsTest(unittest.TestCase): self.assertFalse(self.handler.domains) -# pylint: disable=protected-access -class PathSatisfiedTest(unittest.TestCase): - def setUp(self): - from letsencrypt.client.auth_handler import AuthHandler - self.handler = AuthHandler(None, None, None) - - def test_satisfied_true(self): - dom = ["0", "1", "2", "3", "4"] - self.handler.paths[dom[0]] = [1, 2] - self.handler.responses[dom[0]] = [None, "sat", "sat2", None] - - self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = ["sat", None, None, False] - - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = ["sat"] - - self.handler.paths[dom[3]] = [] - self.handler.responses[dom[3]] = [] - - self.handler.paths[dom[4]] = [] - self.handler.responses[dom[4]] = ["respond... sure"] - - for i in xrange(5): - self.assertTrue(self.handler._path_satisfied(dom[i])) - - def test_not_satisfied(self): - dom = ["0", "1", "2", "3"] - self.handler.paths[dom[0]] = [1, 2] - self.handler.responses[dom[0]] = ["sat1", None, "sat2", None] - - self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = [None, None, None, None] - - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = [None] - - self.handler.paths[dom[3]] = [0] - self.handler.responses[dom[3]] = [False] - - for i in xrange(3): - self.assertFalse(self.handler._path_satisfied(dom[i])) - - class GenChallengePathTest(unittest.TestCase): """Tests for letsencrypt.client.auth_handler.gen_challenge_path. From 95ba2730f1652d02d8a7c2ea4198cd16c3859d96 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 22 Apr 2015 16:27:54 -0700 Subject: [PATCH 18/40] start of tests for auth_handler --- letsencrypt/acme/messages2.py | 6 +- letsencrypt/client/auth_handler.py | 59 +- letsencrypt/client/tests/acme_util.py | 21 +- letsencrypt/client/tests/auth_handler_test.py | 553 ++++-------------- 4 files changed, 190 insertions(+), 449 deletions(-) diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index aa56f6da0..86a703155 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -10,7 +10,6 @@ class Error(jose.JSONObjectWithFields, Exception): https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 """ - ERROR_TYPE_NAMESPACE = 'urn:acme:error:' ERROR_TYPE_DESCRIPTIONS = { 'malformed': 'The request message was malformed', @@ -73,6 +72,9 @@ class _Constant(jose.JSONDeSerializable): def __eq__(self, other): return isinstance(other, type(self)) and other.name == self.name + def __ne__(self, other): + return not self.__eq__(other) + class Status(_Constant): """ACME "status" field.""" @@ -133,7 +135,6 @@ class Registration(ResourceBody): :ivar tuple contact: Contact information following ACME spec """ - # on new-reg key server ignores 'key' and populates it based on # JWS.signature.combined.jwk key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) @@ -217,7 +218,6 @@ class Authorization(ResourceBody): :ivar datetime.datetime expires: """ - identifier = jose.Field('identifier', decoder=Identifier.from_json) challenges = jose.Field('challenges', omitempty=True) combinations = jose.Field('combinations', omitempty=True) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index c40d4057a..882303b6d 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -67,6 +67,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes for domain in domains: self.authzr[domain] = self.network.request_domain_challenges( domain, self.account.new_authzr_uri) + self._choose_challenges(domains) # While there are still challenges remaining... @@ -77,7 +78,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # Send all Responses - this modifies dv_c and cont_c self._respond(cont_resp, dv_resp, best_effort) - return self.authzr.values() + # Just make sure all decisions are complete. + self._verify_authzr_complete() + # Only return valid authorizations + return [authzr for authzr in self.authzr.values() + if authzr.body.status == messages2.STATUS_VALID] def _choose_challenges(self, domains): """Retrieve necessary challenges to satisfy server.""" @@ -88,7 +93,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self._get_chall_pref(dom), self.authzr[dom].body.combinations) - dom_dv_c, dom_cont_c = self._challenge_factory( + dom_cont_c, dom_dv_c = self._challenge_factory( dom, path) self.dv_c.extend(dom_dv_c) self.cont_c.extend(dom_cont_c) @@ -123,11 +128,16 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ # TODO: chall_update is a dirty hack to get around acme-spec #105 chall_update = dict() - self._send_responses(self.dv_c, dv_resp, chall_update) - self._send_responses(self.cont_c, cont_resp, chall_update) + active_achalls = [] + active_achalls.extend( + self._send_responses(self.dv_c, dv_resp, chall_update)) + active_achalls.extend( + self._send_responses(self.cont_c, cont_resp, chall_update)) # Check for updated status... self._poll_challenges(chall_update, best_effort) + # This removes challenges from self.dv_c and self.cont_c + self._cleanup_challenges(active_achalls) def _send_responses(self, achalls, resps, chall_update): """Send responses and make sure errors are handled. @@ -136,15 +146,19 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes authzr -> list of outstanding solved annotated challenges """ + active_achalls = [] for achall, resp in itertools.izip(achalls, resps): # Don't send challenges for None and False authenticator responses if resp: - challr = self.network.answer_challenge(achall.challb, resp) + self.network.answer_challenge(achall.challb, resp) + active_achalls.append(achall) if achall.domain in chall_update: chall_update[achall.domain].append(achall) else: chall_update[achall.domain] = [achall] + return active_achalls + def _poll_challenges(self, chall_update, best_effort, min_sleep=3): """Wait for all challenge results to be determined.""" dom_to_check = set(chall_update.keys()) @@ -166,15 +180,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes else: # Right now... just assume a loss and carry on... if best_effort: - # Add to completed list... but remove authzr - del self.authzr[domain] comp_domains.add(domain) + else: raise errors.AuthorizationError( "Failed Authorization procedure for %s" % domain) - self._cleanup_challenges(comp_challs+failed_challs) - dom_to_check -= comp_domains comp_domains.clear() @@ -191,7 +202,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # challenges will be determined here... for achall in achalls: status = self._get_chall_status(self.authzr[domain], achall) - print "Status:", status + # This does nothing for challenges that have yet to be decided yet. if status == messages2.STATUS_VALID: completed.append(achall) @@ -200,16 +211,16 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes return completed, failed - def _get_chall_status(self, authzr, chall): + def _get_chall_status(self, authzr, achall): """Get the status of the challenge. .. warning:: This assumes only one instance of type of challenge in each challenge resource. """ - for authzr_chall in authzr: - if type(authzr_chall) is type(chall): - return chall.status + for authzr_challb in authzr.body.challenges: + if type(authzr_challb.chall) is type(achall.challb.chall): + return achall.challb.status raise errors.AuthorizationError( "Target challenge not found in authorization resource") @@ -219,7 +230,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param str domain: domain for which you are requesting preferences """ - chall_prefs = self.cont_auth.get_chall_pref(domain) + # Make sure to make a copy... + chall_prefs = [] + chall_prefs.extend(self.cont_auth.get_chall_pref(domain)) chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs @@ -249,6 +262,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes for achall in cont_c: self.cont_c.remove(achall) + def _verify_authzr_complete(self): + for authzr in self.authzr.values(): + if (authzr.body.status != messages2.STATUS_VALID and + authzr.body.status != messages2.STATUS_INVALID): + raise errors.AuthorizationError("Incomplete authorizations") + def _challenge_factory(self, domain, path): """Construct Namedtuple Challenges @@ -266,8 +285,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes recognized """ - dv_chall = set() - cont_chall = set() + dv_chall = [] + cont_chall = [] for index in path: challb = self.authzr[domain].body.challenges[index] @@ -303,11 +322,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes chall.typ) if isinstance(chall, challenges.ContinuityChallenge): - cont_chall.add(achall) + cont_chall.append(achall) elif isinstance(chall, challenges.DVChallenge): - dv_chall.add(achall) + dv_chall.append(achall) - return dv_chall, cont_chall + return cont_chall, dv_chall def gen_challenge_path(challbs, preferences, combinations): diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 8aa5f4cc8..3e5c86a7f 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -78,6 +78,7 @@ def chall_to_challb(chall, status): """ kwargs = { + "chall": chall, "uri": chall.typ+"_uri", "status": messages2.Status(status), } @@ -88,6 +89,24 @@ def chall_to_challb(chall, status): return messages2.ChallengeBody(**kwargs) +# Pending ChallengeBody objects +DVSNI_P = chall_to_challb(DVSNI, "pending") +SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, "pending") +DNS_P = chall_to_challb(DNS, "pending") +RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, "pending") +RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, "pending") +POP_P = chall_to_challb(POP, "pending") + +CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P, + RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] +DV_CHALLENGES_P = [challb for challb in CHALLENGES_P + if isinstance(challb.chall, challenges.DVChallenge)] +CONT_CHALLENGES_P = [ + challb for challb in CHALLENGES_P + if isinstance(challb.chall, challenges.ContinuityChallenge) +] + + def gen_authzr(authz_status, domain, challs, statuses, combos=True): """Generate an authorization resource. @@ -103,7 +122,7 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): ] authz_kwargs = { "identifier": messages2.Identifier( - type=messages2.IDENTIFIER_FQDN, value=domain), + typ=messages2.IDENTIFIER_FQDN, value=domain), "challenges": challbs, } if combos: diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 76c30ed37..02ea947d5 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.auth_handler.""" +import functools import logging import unittest @@ -11,6 +12,7 @@ from letsencrypt.client import account from letsencrypt.client import achallenges from letsencrypt.client import errors from letsencrypt.client import le_util +from letsencrypt.client import network2 from letsencrypt.client.tests import acme_util @@ -25,8 +27,50 @@ TRANSLATE = { } -class SolveChallengesTest(unittest.TestCase): - """verify_identities test.""" +class ChallengeFactoryTest(unittest.TestCase): + # pylint: disable=protected-access + + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + # Account is mocked... + self.handler = AuthHandler( + None, None, None, mock.Mock(key="mock_key")) + + self.dom = "test" + self.handler.authzr[self.dom] = acme_util.gen_authzr( + "pending", self.dom, acme_util.CHALLENGES, ["pending"]*6, False) + + def test_all(self): + cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) + + self.assertEqual( + [achall.chall for achall in cont_c], acme_util.CONT_CHALLENGES) + self.assertEqual( + [achall.chall for achall in dv_c], acme_util.DV_CHALLENGES) + + def test_one_dv_one_cont(self): + cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 4]) + + self.assertEqual( + [achall.chall for achall in cont_c], [acme_util.RECOVERY_TOKEN]) + self.assertEqual([achall.chall for achall in dv_c], [acme_util.DVSNI]) + + def test_unrecognized(self): + self.handler.authzr["failure.com"] = acme_util.gen_authzr( + "pending", "failure.com", + [mock.Mock(chall="chall", typ="unrecognized")], ["pending"]) + + self.assertRaises(errors.LetsEncryptClientError, + self.handler._challenge_factory, "failure.com", [0]) + + +class GetAuthorizationsTest(unittest.TestCase): + """get_authorizations test. + + This tests everything except for all functions under _poll_challenges. + + """ def setUp(self): from letsencrypt.client.auth_handler import AuthHandler @@ -41,294 +85,68 @@ class SolveChallengesTest(unittest.TestCase): self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp - self.account = account.Account(None, le_util.Key("filepath", "pem")) + self.mock_account = mock.Mock(key=le_util.Key("file_path", "PEM")) + self.mock_net = mock.MagicMock(spec=network2.Network) self.handler = AuthHandler( - self.mock_dv_auth, self.mock_cont_auth, None, account) + self.mock_dv_auth, self.mock_cont_auth, + self.mock_net, self.mock_account) logging.disable(logging.CRITICAL) def tearDown(self): logging.disable(logging.NOTSET) - def test_name1_dvsni1(self): - # pylint: disable=protected-access - dom = "0" - # Note: - self.handler.dv_c = [] - cont_resp, dv_resp = self.handler._solve_challenges() + @mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges") + def test_name1_dvsni1(self, mock_poll): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.DV_CHALLENGES) - self.assertEqual(len(self.handler.responses[dom]), 1) + mock_poll.side_effect = self._validate_all - self.assertEqual("DVSNI0", self.handler.responses[dom][0]) - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.cont_c), 0) + authzr = self.handler.get_authorizations(["0"]) - def test_name1_rectok1(self): - dom = "0" - msg = messages.Challenge( - session_id=dom, nonce="nonce0", combinations=[], - challenges=[acme_util.RECOVERY_TOKEN]) - self.handler.add_chall_msg(dom, msg, "dummy_key") + self.assertEqual(self.mock_net.answer_challenge.call_count, 1) - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual(len(self.handler.responses[dom]), 1) - - # Test if statement for dv_auth perform - self.assertEqual(self.mock_cont_auth.perform.call_count, 1) - self.assertEqual(self.mock_dv_auth.perform.call_count, 0) - - self.assertEqual("RecoveryToken0", self.handler.responses[dom][0]) - # Assert 1 domain - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.cont_c), 1) - # Assert 1 auth challenge, 0 dv - self.assertEqual(len(self.handler.dv_c[dom]), 0) - self.assertEqual(len(self.handler.cont_c[dom]), 1) - - def test_name5_dvsni5(self): - for i in xrange(5): - self.handler.add_chall_msg( - str(i), - messages.Challenge(session_id=str(i), nonce="nonce%d" % i, - challenges=[acme_util.DVSNI], - combinations=[]), - "dummy_key") - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 5) - self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.cont_c), 5) - # Each message contains 1 auth, 0 client - - # Test proper call count for methods - self.assertEqual(self.mock_cont_auth.perform.call_count, 0) - self.assertEqual(self.mock_dv_auth.perform.call_count, 1) - - for i in xrange(5): - dom = str(i) - self.assertEqual(len(self.handler.responses[dom]), 1) - self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.cont_c[dom]), 0) - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.DVSNI)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name1_auth(self, mock_chall_path): - dom = "0" - - self.handler.add_chall_msg( - dom, - messages.Challenge( - session_id="0", nonce="nonce0", - challenges=acme_util.DV_CHALLENGES, - combinations=acme_util.gen_combos(acme_util.DV_CHALLENGES)), - "dummy_key") - - path = gen_path([acme_util.SIMPLE_HTTPS], acme_util.DV_CHALLENGES) - mock_chall_path.return_value = path - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual(len(self.handler.responses[dom]), - len(acme_util.DV_CHALLENGES)) - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.cont_c), 1) - - # Test if statement for cont_auth perform - self.assertEqual(self.mock_cont_auth.perform.call_count, 0) - self.assertEqual(self.mock_dv_auth.perform.call_count, 1) + self.assertEqual(mock_poll.call_count, 1) + chall_update = mock_poll.call_args[0][0] + self.assertEqual(chall_update.keys(), ["0"]) + self.assertEqual(len(chall_update.values()), 1) + self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1) + self.assertEqual(self.mock_cont_auth.cleanup.call_count, 0) + # Test if list first element is DVSNI, use typ because it is an achall self.assertEqual( - self.handler.responses[dom], - self._get_exp_response(dom, path, acme_util.DV_CHALLENGES)) + self.mock_dv_auth.cleanup.call_args[0][0][0].typ, "dvsni") - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.cont_c[dom]), 0) - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.SimpleHTTPS)) + self.assertEqual(len(authzr), 1) - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name1_all(self, mock_chall_path): - dom = "0" + @mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges") + def test_name3_dvsni3_rectok_3(self, mock_poll): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.CHALLENGES) - combos = acme_util.gen_combos(acme_util.CHALLENGES) - self.handler.add_chall_msg( - dom, - messages.Challenge( - session_id=dom, nonce="nonce0", challenges=acme_util.CHALLENGES, - combinations=combos), - "dummy_key") + mock_poll.side_effect = self._validate_all - path = gen_path([acme_util.SIMPLE_HTTPS, acme_util.RECOVERY_TOKEN], - acme_util.CHALLENGES) - mock_chall_path.return_value = path + authzr = self.handler.get_authorizations(["0", "1", "2"]) - self.handler._satisfy_challenges() # pylint: disable=protected-access + self.assertEqual(self.mock_net.answer_challenge.call_count, 6) - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual( - len(self.handler.responses[dom]), len(acme_util.CHALLENGES)) - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.cont_c), 1) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.cont_c[dom]), 1) + # Check poll call + self.assertEqual(mock_poll.call_count, 1) + chall_update = mock_poll.call_args[0][0] + self.assertEqual(len(chall_update.keys()), 3) + self.assertTrue("0" in chall_update.keys()) + self.assertEqual(len(chall_update["0"]), 2) + self.assertTrue("1" in chall_update.keys()) + self.assertEqual(len(chall_update["1"]), 2) + self.assertTrue("2" in chall_update.keys()) + self.assertEqual(len(chall_update["2"]), 2) - self.assertEqual( - self.handler.responses[dom], - self._get_exp_response(dom, path, acme_util.CHALLENGES)) - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.SimpleHTTPS)) - self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall, - achallenges.RecoveryToken)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name5_all(self, mock_chall_path): - combos = acme_util.gen_combos(acme_util.CHALLENGES) - for i in xrange(5): - self.handler.add_chall_msg( - str(i), - messages.Challenge( - session_id=str(i), nonce="nonce%d" % i, - challenges=acme_util.CHALLENGES, combinations=combos), - "dummy_key") - - path = gen_path([acme_util.DVSNI, acme_util.RECOVERY_CONTACT], - acme_util.CHALLENGES) - mock_chall_path.return_value = path - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 5) - for i in xrange(5): - self.assertEqual( - len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES)) - self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.cont_c), 5) - - for i in xrange(5): - dom = str(i) - self.assertEqual( - self.handler.responses[dom], - self._get_exp_response(dom, path, acme_util.CHALLENGES)) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.cont_c[dom]), 1) - - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.DVSNI)) - self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall, - achallenges.RecoveryContact)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name5_mix(self, mock_chall_path): - paths = [] - chosen_chall = [[acme_util.DNS], - [acme_util.DVSNI], - [acme_util.SIMPLE_HTTPS, acme_util.POP], - [acme_util.SIMPLE_HTTPS], - [acme_util.DNS, acme_util.RECOVERY_TOKEN]] - challenge_list = [acme_util.DV_CHALLENGES, - [acme_util.DVSNI], - acme_util.CHALLENGES, - acme_util.DV_CHALLENGES, - acme_util.CHALLENGES] - - # Combos doesn't matter since I am overriding the gen_path function - for i in xrange(5): - dom = str(i) - paths.append(gen_path(chosen_chall[i], challenge_list[i])) - self.handler.add_chall_msg( - dom, - messages.Challenge( - session_id=dom, nonce="nonce%d" % i, - challenges=challenge_list[i], combinations=[]), - "dummy_key") - - mock_chall_path.side_effect = paths - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 5) - self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.cont_c), 5) - - for i in xrange(5): - dom = str(i) - resp = self._get_exp_response(i, paths[i], challenge_list[i]) - self.assertEqual(self.handler.responses[dom], resp) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual( - len(self.handler.cont_c[dom]), len(chosen_chall[i]) - 1) - - self.assertTrue(isinstance( - self.handler.dv_c["0"][0].achall, achallenges.DNS)) - self.assertTrue(isinstance( - self.handler.dv_c["1"][0].achall, achallenges.DVSNI)) - self.assertTrue(isinstance( - self.handler.dv_c["2"][0].achall, achallenges.SimpleHTTPS)) - self.assertTrue(isinstance( - self.handler.dv_c["3"][0].achall, achallenges.SimpleHTTPS)) - self.assertTrue(isinstance( - self.handler.dv_c["4"][0].achall, achallenges.DNS)) - - self.assertTrue(isinstance(self.handler.cont_c["2"][0].achall, - achallenges.ProofOfPossession)) - self.assertTrue(isinstance( - self.handler.cont_c["4"][0].achall, achallenges.RecoveryToken)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_perform_exception_cleanup(self, mock_chall_path): - """3 Challenge messages... fail perform... clean up.""" - # pylint: disable=protected-access - self.mock_dv_auth.perform.side_effect = errors.LetsEncryptDvsniError - - combos = acme_util.gen_combos(acme_util.CHALLENGES) - - for i in xrange(3): - self.handler.add_chall_msg( - str(i), - messages2.Challenge( - session_id=str(i), nonce="nonce%d" % i, - challenges=acme_util.CHALLENGES, combinations=combos), - "dummy_key") - - mock_chall_path.side_effect = [ - gen_path([acme_util.DVSNI, acme_util.POP], acme_util.CHALLENGES), - gen_path([acme_util.POP], acme_util.CHALLENGES), - gen_path([acme_util.DVSNI], acme_util.CHALLENGES), - ] - - # This may change in the future... but for now catch the error - self.assertRaises(errors.LetsEncryptAuthHandlerError, - self.handler._satisfy_challenges) - - # Verify cleanup is actually run correctly - self.assertEqual(self.mock_dv_auth.cleanup.call_count, 2) - self.assertEqual(self.mock_cont_auth.cleanup.call_count, 2) - - - dv_cleanup_args = self.mock_dv_auth.cleanup.call_args_list - cont_cleanup_args = self.mock_cont_auth.cleanup.call_args_list - - # Check DV cleanup - for i in xrange(2): - dv_chall_list = dv_cleanup_args[i][0][0] - self.assertEqual(len(dv_chall_list), 1) - self.assertTrue( - isinstance(dv_chall_list[0], achallenges.DVSNI)) - - - # Check Auth cleanup - for i in xrange(2): - cont_chall_list = cont_cleanup_args[i][0][0] - self.assertEqual(len(cont_chall_list), 1) - self.assertTrue( - isinstance(cont_chall_list[0], achallenges.ProofOfPossession)) + self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1) + self.assertEqual(self.mock_cont_auth.cleanup.call_count, 1) + self.assertEqual(len(authzr), 3) def _get_exp_response(self, domain, path, challs): # pylint: disable=no-self-use @@ -338,134 +156,12 @@ class SolveChallengesTest(unittest.TestCase): return exp_resp - -# pylint: disable=protected-access -class GetAuthorizationsTest(unittest.TestCase): - def setUp(self): - from letsencrypt.client.auth_handler import AuthHandler - - self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator") - self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator") - - self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") - self.mock_acme_auth = mock.MagicMock(name="acme_authorization") - - self.iteration = 0 - - self.handler = AuthHandler( - self.mock_dv_auth, self.mock_cont_auth, None) - - self.handler._satisfy_challenges = self.mock_sat_chall - self.handler.acme_authorization = self.mock_acme_auth - - def test_solved3_at_once(self): - # Set 3 DVSNI challenges - for i in xrange(3): - self.handler.add_chall_msg( - str(i), - messages.Challenge( - session_id=str(i), nonce="nonce%d" % i, - challenges=[acme_util.DVSNI], combinations=[]), - "dummy_key") - - self.mock_sat_chall.side_effect = self._sat_solved_at_once - self.handler.get_authorizations() - - self.assertEqual(self.mock_sat_chall.call_count, 1) - self.assertEqual(self.mock_acme_auth.call_count, 3) - - exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")] - self.assertEqual( - self.mock_acme_auth.call_args_list, exp_call_list) - self._test_finished() - - def _sat_solved_at_once(self): - for i in xrange(3): - dom = str(i) - self.handler.responses[dom] = ["DVSNI%d" % i] - self.handler.paths[dom] = [0] - # Assignment was > 80 char... - dv_c, c_c = self.handler._challenge_factory(dom, [0]) - - self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c - - def test_progress_failure(self): - self.handler.add_chall_msg( - "0", - messages.Challenge( - session_id="0", nonce="nonce0", challenges=acme_util.CHALLENGES, - combinations=[]), - "dummy_key") - - # Don't do anything to satisfy challenges - self.mock_sat_chall.side_effect = self._sat_failure - - self.assertRaises( - errors.LetsEncryptAuthHandlerError, self.handler.get_authorizations) - - # Check to make sure program didn't loop - self.assertEqual(self.mock_sat_chall.call_count, 1) - - def _sat_failure(self): - dom = "0" - self.handler.paths[dom] = gen_path( - [acme_util.DNS, acme_util.RECOVERY_TOKEN], - self.handler.msgs[dom].challenges) - dv_c, c_c = self.handler._challenge_factory( - dom, self.handler.paths[dom]) - self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c - - def test_incremental_progress(self): - for dom, challs in [("0", acme_util.CHALLENGES), - ("1", acme_util.DV_CHALLENGES)]: - self.handler.add_chall_msg( - dom, - messages.Challenge(session_id=dom, nonce="nonce", - combinations=[], challenges=challs), - "dummy_key") - - self.mock_sat_chall.side_effect = self._sat_incremental - - self.handler.get_authorizations() - - self._test_finished() - self.assertEqual(self.mock_acme_auth.call_args_list, - [mock.call("1"), mock.call("0")]) - - def _sat_incremental(self): - # Exact responses don't matter, just path/response match - if self.iteration == 0: - # Only solve one of "0" required challs - self.handler.responses["0"][1] = "onecomplete" - self.handler.responses["0"][3] = None - self.handler.responses["1"] = [None, None, "goodresp"] - self.handler.paths["0"] = [1, 3] - self.handler.paths["1"] = [2] - # This is probably overkill... but set it anyway - dv_c, c_c = self.handler._challenge_factory("0", [1, 3]) - self.handler.dv_c["0"], self.handler.cont_c["0"] = dv_c, c_c - dv_c, c_c = self.handler._challenge_factory("1", [2]) - self.handler.dv_c["1"], self.handler.cont_c["1"] = dv_c, c_c - - self.iteration += 1 - - elif self.iteration == 1: - # Quick check to make sure it was actually completed. - self.assertEqual( - self.mock_acme_auth.call_args_list, [mock.call("1")]) - self.handler.responses["0"][1] = "now_finish" - self.handler.responses["0"][3] = "finally!" - - else: - raise errors.LetsEncryptAuthHandlerError( - "Failed incremental test: too many invocations") - - def _test_finished(self): - self.assertFalse(self.handler.msgs) - self.assertFalse(self.handler.dv_c) - self.assertFalse(self.handler.responses) - self.assertFalse(self.handler.paths) - self.assertFalse(self.handler.domains) + def _validate_all(self, unused1, unused2): + for dom in self.handler.authzr.keys(): + azr = self.handler.authzr[dom] + self.handler.authzr[dom] = acme_util.gen_authzr( + "valid", dom, [challb.chall for challb in azr.body.challenges], + ["valid"]*len(azr.body.challenges), azr.body.combinations) class GenChallengePathTest(unittest.TestCase): @@ -481,42 +177,42 @@ class GenChallengePathTest(unittest.TestCase): logging.disable(logging.NOTSET) @classmethod - def _call(cls, challs, preferences, combinations): + def _call(cls, challbs, preferences, combinations): from letsencrypt.client.auth_handler import gen_challenge_path - return gen_challenge_path(challs, preferences, combinations) + return gen_challenge_path(challbs, preferences, combinations) def test_common_case(self): """Given DVSNI and SimpleHTTPS with appropriate combos.""" - challs = (acme_util.DVSNI, acme_util.SIMPLE_HTTPS) + challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P) prefs = [challenges.DVSNI] combos = ((0,), (1,)) # Smart then trivial dumb path test - self.assertEqual(self._call(challs, prefs, combos), (0,)) - self.assertTrue(self._call(challs, prefs, None)) + self.assertEqual(self._call(challbs, prefs, combos), (0,)) + self.assertTrue(self._call(challbs, prefs, None)) # Rearrange order... - self.assertEqual(self._call(challs[::-1], prefs, combos), (1,)) - self.assertTrue(self._call(challs[::-1], prefs, None)) + self.assertEqual(self._call(challbs[::-1], prefs, combos), (1,)) + self.assertTrue(self._call(challbs[::-1], prefs, None)) def test_common_case_with_continuity(self): - challs = (acme_util.RECOVERY_TOKEN, - acme_util.RECOVERY_CONTACT, - acme_util.DVSNI, - acme_util.SIMPLE_HTTPS) + challbs = (acme_util.RECOVERY_TOKEN_P, + acme_util.RECOVERY_CONTACT_P, + acme_util.DVSNI_P, + acme_util.SIMPLE_HTTPS_P) prefs = [challenges.RecoveryToken, challenges.DVSNI] - combos = acme_util.gen_combos(challs) - self.assertEqual(self._call(challs, prefs, combos), (0, 2)) + combos = acme_util.gen_combos(challbs) + self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) # dumb_path() trivial test - self.assertTrue(self._call(challs, prefs, None)) + self.assertTrue(self._call(challbs, prefs, None)) def test_full_cont_server(self): - challs = (acme_util.RECOVERY_TOKEN, - acme_util.RECOVERY_CONTACT, - acme_util.POP, - acme_util.DVSNI, - acme_util.SIMPLE_HTTPS, - acme_util.DNS) + challbs = (acme_util.RECOVERY_TOKEN_P, + acme_util.RECOVERY_CONTACT_P, + acme_util.POP_P, + acme_util.DVSNI_P, + acme_util.SIMPLE_HTTPS_P, + acme_util.DNS_P) # Typical webserver client that can do everything except DNS # Attempted to make the order realistic prefs = [challenges.RecoveryToken, @@ -524,19 +220,19 @@ class GenChallengePathTest(unittest.TestCase): challenges.SimpleHTTPS, challenges.DVSNI, challenges.RecoveryContact] - combos = acme_util.gen_combos(challs) - self.assertEqual(self._call(challs, prefs, combos), (0, 4)) + combos = acme_util.gen_combos(challbs) + self.assertEqual(self._call(challbs, prefs, combos), (0, 4)) # Dumb path trivial test - self.assertTrue(self._call(challs, prefs, None)) + self.assertTrue(self._call(challbs, prefs, None)) def test_not_supported(self): - challs = (acme_util.POP, acme_util.DVSNI) + challbs = (acme_util.POP_P, acme_util.DVSNI_P) prefs = [challenges.DVSNI] combos = ((0, 1),) - self.assertRaises(errors.LetsEncryptAuthHandlerError, - self._call, challs, prefs, combos) + self.assertRaises(errors.AuthorizationError, + self._call, challbs, prefs, combos) class MutuallyExclusiveTest(unittest.TestCase): @@ -595,15 +291,15 @@ class IsPreferredTest(unittest.TestCase): ])) def test_empty_satisfied(self): - self.assertTrue(self._call(acme_util.DNS, frozenset())) + self.assertTrue(self._call(acme_util.DNS_P, frozenset())) def test_mutually_exclusvie(self): self.assertFalse( - self._call(acme_util.DVSNI, frozenset([acme_util.SIMPLE_HTTPS]))) + self._call(acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P]))) def test_mutually_exclusive_same_type(self): self.assertTrue( - self._call(acme_util.DVSNI, frozenset([acme_util.DVSNI]))) + self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P]))) def gen_auth_resp(chall_list): @@ -612,6 +308,12 @@ def gen_auth_resp(chall_list): for chall in chall_list] +def gen_dom_authzr(domain, unused_new_authzr_uri, challs): + """Generates new authzr for domains.""" + return acme_util.gen_authzr( + "pending", domain, challs, ["pending"]*len(challs)) + + def gen_path(required, challs): """Generate a combination by picking ``required`` from ``challs``. @@ -625,5 +327,6 @@ def gen_path(required, challs): """ return [challs.index(chall) for chall in required] + if __name__ == "__main__": unittest.main() From 12899d0c38090c705be488f50bb2a346cd15e94b Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 22 Apr 2015 23:17:53 -0700 Subject: [PATCH 19/40] unittest and lint cleanup --- letsencrypt/client/account.py | 3 +- letsencrypt/client/auth_handler.py | 14 +++++-- letsencrypt/client/client.py | 26 +++++++------ letsencrypt/client/network2.py | 12 +++++- .../client/plugins/apache/configurator.py | 3 +- .../plugins/apache/tests/configurator_test.py | 18 ++++++--- .../client/plugins/apache/tests/dvsni_test.py | 28 ++++++++------ .../standalone/tests/authenticator_test.py | 23 ++++++++---- letsencrypt/client/tests/account_test.py | 8 +--- letsencrypt/client/tests/achallenges_test.py | 24 ++---------- letsencrypt/client/tests/acme_util.py | 10 +++-- letsencrypt/client/tests/auth_handler_test.py | 37 ++++++++++++++----- letsencrypt/client/tests/client_test.py | 9 +++-- .../client/tests/continuity_auth_test.py | 14 +++---- letsencrypt/client/tests/display/util_test.py | 6 +-- letsencrypt/client/tests/network2_test.py | 9 +---- .../client/tests/recovery_token_test.py | 15 +++++--- letsencrypt/scripts/main.py | 2 - 18 files changed, 146 insertions(+), 115 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index ff4956364..52a5867a1 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -1,3 +1,4 @@ +"""Creates ACME accounts for server.""" import logging import os import re @@ -197,5 +198,5 @@ class Account(object): """Scrub email address before using it.""" if re.match(cls.EMAIL_REGEX, email): return bool(not email.startswith(".") and ".." not in email) - logging.warn("Invalid email address: using default address.") + logging.warn("Invalid email address.") return False diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 882303b6d..f27e69081 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -10,8 +10,8 @@ from letsencrypt.client import achallenges from letsencrypt.client import constants from letsencrypt.client import errors - -class AuthHandler(object): # pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-instance-attributes, too-few-public-methods +class AuthHandler(object): """ACME Authorization Handler for a client. :ivar dv_auth: Authenticator capable of solving @@ -108,7 +108,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes if self.dv_c: dv_resp = self.dv_auth.perform(self.dv_c) # This will catch both specific types of errors. - except errors.AuthorizationError as err: + except errors.AuthorizationError: logging.critical("Failure in setting up challenges.") logging.info("Attempting to clean up outstanding challenges...") self._cleanup_challenges() @@ -211,12 +211,18 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes return completed, failed - def _get_chall_status(self, authzr, achall): + def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use """Get the status of the challenge. .. warning:: This assumes only one instance of type of challenge in each challenge resource. + :param authzr: Authorization Resource + :type authzr: :class:`letsencrypt.acme.messages2.AuthorizationResource` + + :param achall: Annotated challenge for which to get status + :type achall: :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + """ for authzr_challb in authzr.body.challenges: if type(authzr_challb.chall) is type(achall.challb.chall): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a6ce76432..96fcc46b1 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -46,7 +46,7 @@ class Client(object): """ - def __init__(self, config, account, dv_auth, installer): + def __init__(self, config, account_, dv_auth, installer): """Initialize a client. :param dv_auth: IAuthenticator that can solve the @@ -56,14 +56,14 @@ class Client(object): :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.account = account + self.account = account_ self.installer = installer # TODO: Allow for other alg types besides RS256 self.network = network2.Network( "https://%s/acme/new-reg" % config.server, - jwk.JWKRSA.load(account.key.pem)) + jwk.JWKRSA.load(self.account.key.pem)) self.config = config @@ -74,7 +74,7 @@ class Client(object): else: self.auth_handler = None - def register(self, save=True): + def register(self): """New Registration with the ACME server.""" self.account = self.network.register_from_account(self.account) if self.account.terms_of_service: @@ -167,16 +167,18 @@ class Client(object): if certr.cert_chain_uri: # TODO: Except chain_cert = self.network.fetch_chain(certr.cert_chain_uri) - chain_file, act_chain_path = le_util.unique_file(chain_path, 0o644) - try: - chain_file.write(chain_cert.to_pem()) - finally: - chain_file.close() + if chain_cert: + chain_file, act_chain_path = le_util.unique_file( + chain_path, 0o644) + try: + chain_file.write(chain_cert.to_pem()) + finally: + chain_file.close() - logging.info("Cert chain written to %s", act_chain_path) + logging.info("Cert chain written to %s", act_chain_path) - # This expects a valid chain file - cert_chain_abspath = os.path.abspath(act_chain_path) + # This expects a valid chain file + cert_chain_abspath = os.path.abspath(act_chain_path) return os.path.abspath(act_cert_path), cert_chain_abspath diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index e740b4240..e26ee741e 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -470,6 +470,15 @@ class Network(object): return self.request_issuance(csr, updated_authzrs), updated_authzrs def _get_cert(self, uri): + """Returns certificate from URI. + + :param str uri: URI of certificate + + :returns: tuple of the form + (response, :class:`letsencrypt.acme.jose.ComparableX509`) + :rtype: tuple + + """ content_type = self.DER_CONTENT_TYPE # TODO: make it a param response = self._get(uri, headers={'Accept': content_type}, content_type=content_type) @@ -521,7 +530,8 @@ class Network(object): """ if certr.cert_chain_uri is not None: - return self._get_cert(certr.cert_chain_uri) + _, cert = self._get_cert(certr.cert_chain_uri) + return cert def revoke(self, certr, when=messages2.Revocation.NOW): """Revoke certificate. diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index f3b952915..e826c011a 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -1008,7 +1008,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): sni_response = apache_dvsni.perform() if sni_response: # Must restart in order to activate the challenges. - # Handled here because we may be able to load up other challenge types + # Handled here because we may be able to load up other challenge + # types self.restart() # Go through all of the challenges and assign them to the proper diff --git a/letsencrypt/client/plugins/apache/tests/configurator_test.py b/letsencrypt/client/plugins/apache/tests/configurator_test.py index 91758d196..ae2097b3e 100644 --- a/letsencrypt/client/plugins/apache/tests/configurator_test.py +++ b/letsencrypt/client/plugins/apache/tests/configurator_test.py @@ -18,6 +18,8 @@ from letsencrypt.client.plugins.apache import parser from letsencrypt.client.plugins.apache.tests import util +from letsencrypt.client.tests import acme_util + class TwoVhost80Test(util.ApacheTest): """Test two standard well configured HTTP vhosts.""" @@ -157,14 +159,18 @@ class TwoVhost80Test(util.ApacheTest): # Note: As more challenges are offered this will have to be expanded auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) achall1 = achallenges.DVSNI( - chall=challenges.DVSNI( - r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", - nonce="37bc5eb75d3e00a19b4f6355845e5a18"), + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + nonce="37bc5eb75d3e00a19b4f6355845e5a18"), + "pending"), domain="encryption-example.demo", key=auth_key) achall2 = achallenges.DVSNI( - chall=challenges.DVSNI( - r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", - nonce="59ed014cac95f77057b1d7a1b2c596ba"), + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + nonce="59ed014cac95f77057b1d7a1b2c596ba"), + "pending"), domain="letsencrypt.demo", key=auth_key) dvsni_ret_val = [ diff --git a/letsencrypt/client/plugins/apache/tests/dvsni_test.py b/letsencrypt/client/plugins/apache/tests/dvsni_test.py index 9bddfc481..1d1b0e652 100644 --- a/letsencrypt/client/plugins/apache/tests/dvsni_test.py +++ b/letsencrypt/client/plugins/apache/tests/dvsni_test.py @@ -14,6 +14,8 @@ from letsencrypt.client.plugins.apache.obj import Addr from letsencrypt.client.plugins.apache.tests import util +from letsencrypt.client.tests import acme_util + class DvsniPerformTest(util.ApacheTest): """Test the ApacheDVSNI challenge.""" @@ -39,18 +41,22 @@ class DvsniPerformTest(util.ApacheTest): auth_key = le_util.Key(rsa256_file, rsa256_pem) self.achalls = [ achallenges.DVSNI( - chall=challenges.DVSNI( - r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9\xf1" - "\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", - nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", - ), domain="encryption-example.demo", key=auth_key), + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9" + "\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", + nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", + ), "pending"), + domain="encryption-example.demo", key=auth_key), achallenges.DVSNI( - chall=challenges.DVSNI( - r="\xba\xa9\xda? Date: Thu, 23 Apr 2015 13:57:35 -0700 Subject: [PATCH 20/40] fix/add tests --- letsencrypt/client/network2.py | 3 +- .../plugins/nginx/tests/configurator_test.py | 23 ++++++++----- .../client/plugins/nginx/tests/dvsni_test.py | 25 ++++++++++----- letsencrypt/client/tests/network2_test.py | 32 +++++++++++++++++-- 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index e26ee741e..26de5f865 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -530,8 +530,7 @@ class Network(object): """ if certr.cert_chain_uri is not None: - _, cert = self._get_cert(certr.cert_chain_uri) - return cert + return self._get_cert(certr.cert_chain_uri)[1] def revoke(self, certr, when=messages2.Revocation.NOW): """Revoke certificate. diff --git a/letsencrypt/client/plugins/nginx/tests/configurator_test.py b/letsencrypt/client/plugins/nginx/tests/configurator_test.py index 0ac0fd8bc..cb5fef6bf 100644 --- a/letsencrypt/client/plugins/nginx/tests/configurator_test.py +++ b/letsencrypt/client/plugins/nginx/tests/configurator_test.py @@ -5,6 +5,7 @@ import unittest import mock from letsencrypt.acme import challenges +from letsencrypt.acme import messages2 from letsencrypt.client import achallenges from letsencrypt.client import errors @@ -166,15 +167,21 @@ class NginxConfiguratorTest(util.NginxTest): # Note: As more challenges are offered this will have to be expanded auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) achall1 = achallenges.DVSNI( - chall=challenges.DVSNI( - r="foo", - nonce="bar"), - domain="localhost", key=auth_key) + challb=messages2.ChallengeBody( + chall=challenges.DVSNI( + r="foo", + nonce="bar"), + uri="https://ca.org/chall0_uri", + status=messages2.Status("pending"), + ), domain="localhost", key=auth_key) achall2 = achallenges.DVSNI( - chall=challenges.DVSNI( - r="abc", - nonce="def"), - domain="example.com", key=auth_key) + challb=messages2.ChallengeBody( + chall=challenges.DVSNI( + r="abc", + nonce="def"), + uri="https://ca.org/chall1_uri", + status=messages2.Status("pending"), + ), domain="example.com", key=auth_key) dvsni_ret_val = [ challenges.DVSNIResponse(s="irrelevant"), diff --git a/letsencrypt/client/plugins/nginx/tests/dvsni_test.py b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py index a6dfac2e2..66e0cc704 100644 --- a/letsencrypt/client/plugins/nginx/tests/dvsni_test.py +++ b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py @@ -6,6 +6,7 @@ import shutil import mock from letsencrypt.acme import challenges +from letsencrypt.acme import messages2 from letsencrypt.client import achallenges from letsencrypt.client import le_util @@ -35,16 +36,24 @@ class DvsniPerformTest(util.NginxTest): self.achalls = [ achallenges.DVSNI( - chall=challenges.DVSNI( - r="foo", - nonce="bar", + challb=messages2.ChallengeBody( + chall=challenges.DVSNI( + r="foo", + nonce="bar", + ), + uri="https://letsencrypt-ca.org/chall0_uri", + status=messages2.Status("pending"), ), domain="www.example.com", key=auth_key), achallenges.DVSNI( - chall=challenges.DVSNI( - r="\xba\xa9\xda? Date: Thu, 23 Apr 2015 19:12:15 -0700 Subject: [PATCH 21/40] 100% test coverage, account, auth_handler --- letsencrypt/client/auth_handler.py | 99 ++++++++----- letsencrypt/client/network2.py | 3 +- letsencrypt/client/tests/account_test.py | 47 +++++- letsencrypt/client/tests/acme_util.py | 37 ++--- letsencrypt/client/tests/auth_handler_test.py | 135 ++++++++++++++++-- letsencrypt/client/tests/display/ops_test.py | 1 - letsencrypt/client/tests/network2_test.py | 7 + tox.ini | 2 +- 8 files changed, 260 insertions(+), 71 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index f27e69081..32a7d1261 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -79,7 +79,7 @@ class AuthHandler(object): self._respond(cont_resp, dv_resp, best_effort) # Just make sure all decisions are complete. - self._verify_authzr_complete() + self.verify_authzr_complete() # Only return valid authorizations return [authzr for authzr in self.authzr.values() if authzr.body.status == messages2.STATUS_VALID] @@ -112,8 +112,7 @@ class AuthHandler(object): logging.critical("Failure in setting up challenges.") logging.info("Attempting to clean up outstanding challenges...") self._cleanup_challenges() - raise errors.AuthorizationError( - "Unable to perform challenges") + raise assert len(cont_resp) == len(self.cont_c) assert len(dv_resp) == len(self.dv_c) @@ -159,12 +158,14 @@ class AuthHandler(object): return active_achalls - def _poll_challenges(self, chall_update, best_effort, min_sleep=3): + def _poll_challenges( + self, chall_update, best_effort, min_sleep=3, max_rounds=15): """Wait for all challenge results to be determined.""" dom_to_check = set(chall_update.keys()) comp_domains = set() + rounds = 0 - while dom_to_check: + while dom_to_check and rounds < max_rounds: # TODO: Use retry-after... time.sleep(min_sleep) for domain in dom_to_check: @@ -181,13 +182,13 @@ class AuthHandler(object): # 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) dom_to_check -= comp_domains comp_domains.clear() + rounds += 1 def _handle_check(self, domain, achalls): """Returns tuple of ('completed', 'failed').""" @@ -226,7 +227,7 @@ class AuthHandler(object): """ for authzr_challb in authzr.body.challenges: if type(authzr_challb.chall) is type(achall.challb.chall): - return achall.challb.status + return authzr_challb.status raise errors.AuthorizationError( "Target challenge not found in authorization resource") @@ -268,7 +269,13 @@ class AuthHandler(object): for achall in cont_c: self.cont_c.remove(achall) - def _verify_authzr_complete(self): + def verify_authzr_complete(self): + """Verifies that all authorizations have been decided. + + :returns: Whether all authzr are complete + :rtype: bool + + """ for authzr in self.authzr.values(): if (authzr.body.status != messages2.STATUS_VALID and authzr.body.status != messages2.STATUS_INVALID): @@ -298,34 +305,7 @@ class AuthHandler(object): challb = self.authzr[domain].body.challenges[index] chall = challb.chall - if isinstance(chall, challenges.DVSNI): - logging.info(" DVSNI challenge for %s.", domain) - achall = achallenges.DVSNI( - challb=challb, domain=domain, key=self.account.key) - elif isinstance(chall, challenges.SimpleHTTPS): - logging.info(" SimpleHTTPS challenge for %s.", domain) - achall = achallenges.SimpleHTTPS( - challb=challb, domain=domain, key=self.account.key) - elif isinstance(chall, challenges.DNS): - logging.info(" DNS challenge for %s.", domain) - achall = achallenges.DNS(challb=challb, domain=domain) - - elif isinstance(chall, challenges.RecoveryToken): - logging.info(" Recovery Token Challenge for %s.", domain) - achall = achallenges.RecoveryToken(challb=challb, domain=domain) - elif isinstance(chall, challenges.RecoveryContact): - logging.info(" Recovery Contact Challenge for %s.", domain) - achall = achallenges.RecoveryContact( - challb=challb, domain=domain) - elif isinstance(chall, challenges.ProofOfPossession): - logging.info(" Proof-of-Possession Challenge for %s", domain) - achall = achallenges.ProofOfPossession( - challb=challb, domain=domain) - - else: - raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: %s", - chall.typ) + achall = challb_to_achall(challb, self.account.key, domain) if isinstance(chall, challenges.ContinuityChallenge): cont_chall.append(achall) @@ -335,6 +315,53 @@ class AuthHandler(object): return cont_chall, dv_chall +def challb_to_achall(challb, key, domain): + """Converts a ChallengeBody object to an AnnotatedChallenge. + + :param challb: ChallengeBody + :type challb: :class:`letsencrypt.acme.messages2.ChallengeBody` + + :param key: Key + :type key: :class:`letsencrypt.client.le_util.Key` + + :param str domain: Domain of the challb + + :returns: Appropriate AnnotatedChallenge + :rtype: :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + + """ + chall = challb.chall + + if isinstance(chall, challenges.DVSNI): + logging.info(" DVSNI challenge for %s.", domain) + return achallenges.DVSNI( + challb=challb, domain=domain, key=key) + elif isinstance(chall, challenges.SimpleHTTPS): + logging.info(" SimpleHTTPS challenge for %s.", domain) + return achallenges.SimpleHTTPS( + challb=challb, domain=domain, key=key) + elif isinstance(chall, challenges.DNS): + logging.info(" DNS challenge for %s.", domain) + return achallenges.DNS(challb=challb, domain=domain) + + elif isinstance(chall, challenges.RecoveryToken): + logging.info(" Recovery Token Challenge for %s.", domain) + return achallenges.RecoveryToken(challb=challb, domain=domain) + elif isinstance(chall, challenges.RecoveryContact): + logging.info(" Recovery Contact Challenge for %s.", domain) + return achallenges.RecoveryContact( + challb=challb, domain=domain) + elif isinstance(chall, challenges.ProofOfPossession): + logging.info(" Proof-of-Possession Challenge for %s", domain) + return achallenges.ProofOfPossession( + challb=challb, domain=domain) + + else: + raise errors.LetsEncryptClientError( + "Received unsupported challenge of type: %s", + chall.typ) + + def gen_challenge_path(challbs, preferences, combinations): """Generate a plan to get authority over the identity. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 26de5f865..557446e5c 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -150,6 +150,7 @@ class Network(object): response.links['terms-of-service']['url'] if 'terms-of-service' in response.links else terms_of_service) + # TODO: Consider removing this check based on spec clarifications #93 if new_authzr_uri is None: try: new_authzr_uri = response.links['next']['url'] @@ -321,7 +322,7 @@ class Network(object): # TODO: Right now Boulder responds with the authorization resource # instead of a challenge resource... this can be uncommented # once the error is fixed. - return challb + return None # raise errors.NetworkError('"up" Link header missing') challr = messages2.ChallengeResource( authzr_uri=authzr_uri, diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 28d2f16b3..2a675c15c 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.account.""" +import logging import mock import os import pkg_resources @@ -13,6 +14,7 @@ from letsencrypt.acme import messages2 from letsencrypt.client import account from letsencrypt.client import configuration +from letsencrypt.client import errors from letsencrypt.client import le_util from letsencrypt.client.display import util as display_util @@ -22,6 +24,8 @@ class AccountTest(unittest.TestCase): """Tests letsencrypt.client.account.Account.""" def setUp(self): + logging.disable(logging.CRITICAL) + self.accounts_dir = tempfile.mkdtemp("accounts") self.account_keys_dir = os.path.join(self.accounts_dir, "keys") os.makedirs(self.account_keys_dir, 0o700) @@ -51,6 +55,7 @@ class AccountTest(unittest.TestCase): def tearDown(self): shutil.rmtree(self.accounts_dir) + logging.disable(logging.NOTSET) @mock.patch("letsencrypt.client.account.zope.component.getUtility") @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") @@ -67,6 +72,15 @@ class AccountTest(unittest.TestCase): self.assertEqual(acc.key, self.key) self.assertEqual(acc.config, self.config) + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + def test_prompts_cancel(self, mock_util): + # displayer = display_util.FileDisplay(sys.stdout) + # zope.component.provideUtility(displayer) + + mock_util().input.return_value = (display_util.CANCEL, "") + + self.assertTrue(account.Account.from_prompts(self.config) is None) + def test_save_from_existing_account(self): self.test_account.save() acc = account.Account.from_existing_account(self.config, self.email) @@ -84,12 +98,25 @@ class AccountTest(unittest.TestCase): def test_partial_properties(self): partial = account.Account(self.config, self.key) + regr_no_authzr_uri = messages2.RegistrationResource( + uri="uri", + new_authzr_uri=None, + terms_of_service="terms_of_service", + body=messages2.Registration( + recovery_token="recovery_token", agreement="agreement") + ) + partial2 = account.Account( + self.config, self.key, regr=regr_no_authzr_uri) self.assertTrue(partial.uri is None) self.assertTrue(partial.new_authzr_uri is None) self.assertTrue(partial.terms_of_service is None) self.assertTrue(partial.recovery_token is None) + self.assertEqual( + partial2.new_authzr_uri, + "https://letsencrypt-demo.org/acme/new-authz") + def test_partial_account_default(self): partial = account.Account(self.config, self.key) partial.save() @@ -115,9 +142,23 @@ class AccountTest(unittest.TestCase): accs = account.Account.get_accounts(self.config) self.assertEqual(len(accs), 2) + def test_get_accounts_no_accounts(self): + self.assertEqual(account.Account.get_accounts( + mock.Mock(accounts_dir="non-existant")), []) + + def test_failed_existing_account(self): + self.assertRaises( + errors.LetsEncryptClientError, + account.Account.from_existing_account, + self.config, "non-existant@email.org") class SafeEmailTest(unittest.TestCase): """Test safe_email.""" + def setUp(self): + logging.disable(logging.CRITICAL) + + def tearDown(self): + logging.disable(logging.NOTSET) @classmethod def _call(cls, addr): @@ -131,16 +172,16 @@ class SafeEmailTest(unittest.TestCase): "abc_def.jdk@hotmail.museum" ] for addr in addrs: - self.assertTrue(addr, "%s failed." % addr) + self.assertTrue(self._call(addr), "%s failed." % addr) def test_invalid_emails(self): addrs = [ "letsencrypt@letsencrypt..org", ".tbd.ade@gmail.com", - "~/abc_def.jdk@hotmail.museum" + "~/abc_def.jdk@hotmail.museum", ] for addr in addrs: - self.assertTrue(addr, "%s failed." % addr) + self.assertFalse(self._call(addr), "%s failed." % addr) if __name__ == "__main__": diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 213822466..a81c68aa7 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -72,15 +72,11 @@ def gen_combos(challbs): def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name - """Return ChallengeBody from Challenge. - - :param str status: "valid", "invalid", "pending"... - - """ + """Return ChallengeBody from Challenge.""" kwargs = { "chall": chall, "uri": chall.typ+"_uri", - "status": messages2.Status(status), + "status": status, } if status == "valid": @@ -90,12 +86,12 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name # Pending ChallengeBody objects -DVSNI_P = chall_to_challb(DVSNI, "pending") -SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, "pending") -DNS_P = chall_to_challb(DNS, "pending") -RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, "pending") -RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, "pending") -POP_P = chall_to_challb(POP, "pending") +DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING) +SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING) +DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING) +RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING) +RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING) +POP_P = chall_to_challb(POP, messages2.STATUS_PENDING) CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P, RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] @@ -110,17 +106,18 @@ CONT_CHALLENGES_P = [ def gen_authzr(authz_status, domain, challs, statuses, combos=True): """Generate an authorization resource. - :param str authz_status: "valid", "invalid", "pending"... + :param authz_status: Status object + :type authz_status: :class:`letsencrypt.acme.messages2.Status` :param list challs: Challenge objects - :param list statuses: status of each challenge object e.g. "valid"... + :param list statuses: status of each challenge object :param bool combos: Whether or not to add combinations """ # pylint: disable=redefined-outer-name - challbs = [ + challbs = tuple( chall_to_challb(chall, status) for chall, status in itertools.izip(challs, statuses) - ] + ) authz_kwargs = { "identifier": messages2.Identifier( typ=messages2.IDENTIFIER_FQDN, value=domain), @@ -128,12 +125,16 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): } if combos: authz_kwargs.update({"combinations": gen_combos(challbs)}) - if authz_status == "valid": + if authz_status == messages2.STATUS_VALID: now = datetime.datetime.now() authz_kwargs.update({ - "status": messages2.Status(authz_status), + "status": authz_status, "expires": datetime.datetime(now.year, now.month+1, now.day), }) + else: + authz_kwargs.update({ + "status": authz_status, + }) # pylint: disable=star-args return messages2.AuthorizationResource( diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 1f99c8f59..7445c1666 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -6,6 +6,7 @@ import unittest import mock from letsencrypt.acme import challenges +from letsencrypt.acme import messages2 from letsencrypt.client import errors from letsencrypt.client import le_util @@ -36,7 +37,8 @@ class ChallengeFactoryTest(unittest.TestCase): self.dom = "test" self.handler.authzr[self.dom] = acme_util.gen_authzr( - "pending", self.dom, acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.dom, acme_util.CHALLENGES, + [messages2.STATUS_PENDING]*6, False) def test_all(self): cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) @@ -55,8 +57,9 @@ class ChallengeFactoryTest(unittest.TestCase): def test_unrecognized(self): self.handler.authzr["failure.com"] = acme_util.gen_authzr( - "pending", "failure.com", - [mock.Mock(chall="chall", typ="unrecognized")], ["pending"]) + messages2.STATUS_PENDING, "failure.com", + [mock.Mock(chall="chall", typ="unrecognized")], + [messages2.STATUS_PENDING]) self.assertRaises(errors.LetsEncryptClientError, self.handler._challenge_factory, "failure.com", [0]) @@ -145,6 +148,14 @@ class GetAuthorizationsTest(unittest.TestCase): self.assertEqual(len(authzr), 3) + def test_perform_failure(self): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.CHALLENGES) + self.mock_dv_auth.perform.side_effect = errors.AuthorizationError + + self.assertRaises(errors.AuthorizationError, + self.handler.get_authorizations, ["0"]) + def _get_exp_response(self, domain, path, challs): # pylint: disable=no-self-use exp_resp = [None] * len(challs) @@ -157,28 +168,129 @@ class GetAuthorizationsTest(unittest.TestCase): for dom in self.handler.authzr.keys(): azr = self.handler.authzr[dom] self.handler.authzr[dom] = acme_util.gen_authzr( - "valid", dom, [challb.chall for challb in azr.body.challenges], - ["valid"]*len(azr.body.challenges), azr.body.combinations) + messages2.STATUS_VALID, + dom, + [challb.chall for challb in azr.body.challenges], + [messages2.STATUS_VALID]*len(azr.body.challenges), + azr.body.combinations) class PollChallengesTest(unittest.TestCase): + # pylint: disable=protected-access """Test poll challenges.""" def setUp(self): + from letsencrypt.client.auth_handler import challb_to_achall from letsencrypt.client.auth_handler import AuthHandler - # Account is mocked... + + # Account and network are mocked... + self.mock_net = mock.MagicMock() self.handler = AuthHandler( - None, None, None, mock.Mock(key="mock_key")) + None, None, self.mock_net, mock.Mock(key="mock_key")) self.doms = ["0", "1", "2"] self.handler.authzr[self.doms[0]] = acme_util.gen_authzr( - "pending", self.doms[0], acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.doms[0], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) self.handler.authzr[self.doms[1]] = acme_util.gen_authzr( - "pending", self.doms[1], acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.doms[1], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) self.handler.authzr[self.doms[2]] = acme_util.gen_authzr( - "pending", self.doms[2], acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.doms[2], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) + + self.chall_update = {} + for dom in self.doms: + self.chall_update[dom] = [ + challb_to_achall(challb, "dummy_key", dom) + for challb in self.handler.authzr[dom].body.challenges] + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid + self.handler._poll_challenges(self.chall_update, False) + + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_VALID) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges_failure_best_effort(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid + self.handler._poll_challenges(self.chall_update, True) + + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_PENDING) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges_failure(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid + self.assertRaises(errors.AuthorizationError, + self.handler._poll_challenges, + self.chall_update, False) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_unable_to_find_challenge_status(self, unused_mock_time): + from letsencrypt.client.auth_handler import challb_to_achall + self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid + self.chall_update[self.doms[0]].append( + challb_to_achall(acme_util.RECOVERY_CONTACT_P, "key", self.doms[0])) + self.assertRaises( + errors.AuthorizationError, + self.handler._poll_challenges, self.chall_update, False) + + def test_verify_authzr_failure(self): + self.assertRaises( + errors.AuthorizationError, self.handler.verify_authzr_complete) + + + def _mock_poll_solve_one_valid(self, authzr): + # Pending here because my dummy script won't change the full status. + # Basically it didn't raise an error and it stopped earlier than + # Making all challenges invalid which would make mock_poll_solve_one + # change authzr to invalid + return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_VALID) + + def _mock_poll_solve_one_invalid(self, authzr): + return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID) + + def _mock_poll_solve_one_chall(self, authzr, desired_status): + # pylint: disable=no-self-use + """Dummy method that solves one chall at a time to desired_status. + + When all are solved.. it changes authzr.status to desired_status + + """ + new_challbs = authzr.body.challenges + for challb in authzr.body.challenges: + if challb.status != desired_status: + new_challbs = tuple( + challb_temp if challb_temp != challb + else acme_util.chall_to_challb(challb.chall, desired_status) + for challb_temp in authzr.body.challenges + ) + break + + if all(test_challb.status == desired_status + for test_challb in new_challbs): + status_ = desired_status + else: + status_ = authzr.body.status + + new_authzr = messages2.AuthorizationResource( + uri=authzr.uri, + new_cert_uri=authzr.new_cert_uri, + body=messages2.Authorization( + identifier=authzr.body.identifier, + challenges=new_challbs, + combinations=authzr.body.combinations, + key=authzr.body.key, + contact=authzr.body.contact, + status=status_, + ), + ) + return (new_authzr, "response") class GenChallengePathTest(unittest.TestCase): """Tests for letsencrypt.client.auth_handler.gen_challenge_path. @@ -328,7 +440,8 @@ def gen_auth_resp(chall_list): def gen_dom_authzr(domain, unused_new_authzr_uri, challs): """Generates new authzr for domains.""" return acme_util.gen_authzr( - "pending", domain, challs, ["pending"]*len(challs)) + messages2.STATUS_PENDING, domain, challs, + [messages2.STATUS_PENDING]*len(challs)) def gen_path(required, challs): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 73b6ba430..9359e53d0 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -85,7 +85,6 @@ class ChooseAccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.display.ops.util") def test_one(self, mock_util): - print self.acc1 mock_util().menu.return_value = (display_util.OK, 0) self.assertEqual(self._call([self.acc1]), self.acc1) diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py index f263dce9e..f2fe32d6d 100644 --- a/letsencrypt/client/tests/network2_test.py +++ b/letsencrypt/client/tests/network2_test.py @@ -233,6 +233,12 @@ class NetworkTest(unittest.TestCase): self.assertRaises( errors.UnexpectedUpdate, self.net.update_registration, self.regr) + def test_agree_to_tos(self): + self.net.update_registration = mock.Mock() + self.net.agree_to_tos(self.regr) + regr = self.net.update_registration.call_args[0][0] + self.assertEqual(self.regr.terms_of_service, regr.body.agreement) + def test_request_challenges(self): self.response.status_code = httplib.CREATED self.response.headers['Location'] = self.authzr.uri @@ -280,6 +286,7 @@ class NetworkTest(unittest.TestCase): @unittest.skip("Skip til challenge_resource boulder issue is resolved") def test_answer_challenge_missing_next(self): + # TODO: Change once acme-spec #93 is resolved/boulder issue self._mock_post_get() self.assertRaises(errors.NetworkError, self.net.answer_challenge, self.challr.body, challenges.DNSResponse()) diff --git a/tox.ini b/tox.ini index fe9da1865..47b509203 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=87 + python setup.py nosetests --with-coverage --cover-min-percentage=89 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From ee2e0948f4a7a68696e9b969fed486a2dc9d68b7 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 24 Apr 2015 03:38:49 -0700 Subject: [PATCH 22/40] fix py26 --- letsencrypt/client/tests/auth_handler_test.py | 1 - letsencrypt/client/tests/network2_test.py | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 7445c1666..c6e3b6153 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -244,7 +244,6 @@ class PollChallengesTest(unittest.TestCase): self.assertRaises( errors.AuthorizationError, self.handler.verify_authzr_complete) - def _mock_poll_solve_one_valid(self, authzr): # Pending here because my dummy script won't change the full status. # Basically it didn't raise an error and it stopped earlier than diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py index f2fe32d6d..5605cc8aa 100644 --- a/letsencrypt/client/tests/network2_test.py +++ b/letsencrypt/client/tests/network2_test.py @@ -284,12 +284,13 @@ class NetworkTest(unittest.TestCase): self.assertRaises(errors.UnexpectedUpdate, self.net.answer_challenge, self.challr.body.update(uri='foo'), chall_response) - @unittest.skip("Skip til challenge_resource boulder issue is resolved") def test_answer_challenge_missing_next(self): # TODO: Change once acme-spec #93 is resolved/boulder issue self._mock_post_get() - self.assertRaises(errors.NetworkError, self.net.answer_challenge, - self.challr.body, challenges.DNSResponse()) + self.assertTrue(self.net.answer_challenge( + self.challr.body, challenges.DNSResponse()) is None) + # self.assertRaises(errors.NetworkError, self.net.answer_challenge, + # self.challr.body, challenges.DNSResponse()) def test_retry_after_date(self): self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' From 0371838a91feda70dcbb26701ea33f14ebc5bb26 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 27 Apr 2015 12:42:50 -0700 Subject: [PATCH 23/40] more unit tests/better flow --- letsencrypt/acme/messages2_test.py | 7 +++ letsencrypt/client/account.py | 23 ++++---- letsencrypt/client/configuration.py | 8 ++- letsencrypt/client/constants.py | 6 ++ letsencrypt/client/crypto_util.py | 12 +++- letsencrypt/client/network2.py | 8 +++ .../client/tests/configuration_test.py | 11 +++- letsencrypt/client/tests/crypto_util_test.py | 55 +++++++++++++++++++ letsencrypt/scripts/main.py | 9 ++- 9 files changed, 121 insertions(+), 18 deletions(-) diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py index b9695ecd6..e1d5efe47 100644 --- a/letsencrypt/acme/messages2_test.py +++ b/letsencrypt/acme/messages2_test.py @@ -58,6 +58,7 @@ class ConstantTest(unittest.TestCase): self.MockConstant = MockConstant # pylint: disable=invalid-name self.const_a = MockConstant('a') self.const_b = MockConstant('b') + self.const_a_prime = MockConstant('a') def test_to_partial_json(self): self.assertEqual('a', self.const_a.to_partial_json()) @@ -75,6 +76,12 @@ class ConstantTest(unittest.TestCase): self.assertEqual('MockConstant(a)', repr(self.const_a)) self.assertEqual('MockConstant(b)', repr(self.const_b)) + def test_equality(self): + self.assertFalse(self.const_a == self.const_b) + self.assertTrue(self.const_a == self.const_a_prime) + + self.assertTrue(self.const_a != self.const_b) + self.assertFalse(self.const_a != self.const_a_prime) class RegistrationTest(unittest.TestCase): """Tests for letsencrypt.acme.messages2.Registration.""" diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index 52a5867a1..ed37b6446 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -180,18 +180,21 @@ class Account(object): :rtype: :class:`letsencrypt.client.account.Account` """ - code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address (optional)") - if code == display_util.OK: - email = email if email != "" else None + while True: + code, email = zope.component.getUtility(interfaces.IDisplay).input( + "Enter email address (optional, press Enter to skip)") - le_util.make_or_verify_dir( - config.account_keys_dir, 0o700, os.geteuid()) - key = crypto_util.init_save_key( - config.rsa_key_size, config.account_keys_dir, email) - return cls(config, key, email) + if code == display_util.OK: + if email == "" or cls.safe_email(email): + email = email if email != "" else None - return None + le_util.make_or_verify_dir( + config.account_keys_dir, 0o700, os.geteuid()) + key = crypto_util.init_save_key( + config.rsa_key_size, config.account_keys_dir, email) + return cls(config, key, email) + else: + return None @classmethod def safe_email(cls, email): diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index 7c5aecbcc..5474a497d 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -51,12 +51,14 @@ class NamespaceConfig(object): @property def accounts_dir(self): #pylint: disable=missing-docstring return os.path.join( - self.namespace.config_dir, "accounts", self.namespace.server) + self.namespace.config_dir, constants.ACCOUNTS_DIR, + self.namespace.server.partition(":")[0]) @property def account_keys_dir(self): #pylint: disable=missing-docstring - return os.path.join(self.namespace.config_dir, "accounts", - self.namespace.server, "keys") + return os.path.join( + self.namespace.config_dir, constants.ACCOUNTS_DIR, + self.namespace.server.partition(":")[0], constants.ACCOUNT_KEYS_DIR) # TODO: This should probably include the server name @property diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 02fab62cb..20f735779 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -61,6 +61,12 @@ CERT_KEY_BACKUP_DIR = "keys-certs" """Directory where all certificates and keys are stored (relative to IConfig.work_dir. Used for easy revocation.""" +ACCOUNTS_DIR = "accounts" +"""Directory where all accounts are saved.""" + +ACCOUNT_KEYS_DIR = "keys" +"""Directory where account keys are saved. Relative to ACCOUNTS_DIR.""" + REC_TOKEN_DIR = "recovery_tokens" """Directory where all recovery tokens are saved (relative to IConfig.work_dir).""" diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index e4b4311b5..c2b761d59 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -30,6 +30,9 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): :param str key_dir: Key save directory. :param str keyname: Filename of key + :returns: Key + :rtype: :class:`letsencrypt.client.le_util.Key` + :raises ValueError: If unable to generate the key given key_size. """ @@ -40,7 +43,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): raise err # Save file - le_util.make_or_verify_dir(key_dir, 0o700) + le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid()) key_f, key_path = le_util.unique_file( os.path.join(key_dir, keyname), 0o600) key_f.write(key_pem) @@ -51,7 +54,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): return le_util.Key(key_path, key_pem) -def init_save_csr(privkey, names, cert_dir): +def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"): """Initialize a CSR with the given private key. :param privkey: Key to include in the CSR @@ -61,13 +64,16 @@ def init_save_csr(privkey, names, cert_dir): :param str cert_dir: Certificate save directory. + :returns: CSR + :rtype: :class:`letsencrypt.client.le_util.CSR` + """ csr_pem, csr_der = make_csr(privkey.pem, names) # Save CSR le_util.make_or_verify_dir(cert_dir, 0o755) csr_f, csr_filename = le_util.unique_file( - os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) + os.path.join(cert_dir, csrname), 0o644) csr_f.write(csr_pem) csr_f.close() diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 557446e5c..1793cdb1c 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -89,6 +89,8 @@ class Network(object): try: # TODO: This is insufficient or doesn't work as intended. + logging.error("Error: %s", jobj) + logging.error("Response from server: %s", response.content) raise messages2.Error.from_json(jobj) except jose.DeserializationError as error: # Couldn't deserialize JSON object @@ -536,9 +538,15 @@ class Network(object): def revoke(self, certr, when=messages2.Revocation.NOW): """Revoke certificate. + :param certr: Certificate Resource + :type certr: `.CertificateResource` + :param when: When should the revocation take place? Takes the same values as `.messages2.Revocation.revoke`. + :raises letsencrypt.client.errors.NetworkError: If revocation is + unsuccessful. + """ rev = messages2.Revocation(revoke=when, authorizations=tuple( authzr.uri for authzr in certr.authzrs)) diff --git a/letsencrypt/client/tests/configuration_test.py b/letsencrypt/client/tests/configuration_test.py index dde1f44cb..9385dbde3 100644 --- a/letsencrypt/client/tests/configuration_test.py +++ b/letsencrypt/client/tests/configuration_test.py @@ -10,7 +10,8 @@ class NamespaceConfigTest(unittest.TestCase): def setUp(self): from letsencrypt.client.configuration import NamespaceConfig namespace = mock.MagicMock( - work_dir='/tmp/foo', foo='bar', server='acme-server.org:443') + config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar', + server='acme-server.org:443') self.config = NamespaceConfig(namespace) def test_proxy_getattr(self): @@ -23,11 +24,19 @@ class NamespaceConfigTest(unittest.TestCase): constants.IN_PROGRESS_DIR = '../p' constants.CERT_KEY_BACKUP_DIR = 'c/' constants.REC_TOKEN_DIR = '/r' + constants.ACCOUNTS_DIR = 'acc' + constants.ACCOUNT_KEYS_DIR = 'keys' + self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual( self.config.cert_key_backup, '/tmp/foo/c/acme-server.org') self.assertEqual(self.config.rec_token_dir, '/r') + self.assertEqual( + self.config.accounts_dir, '/tmp/config/acc/acme-server.org') + self.assertEqual( + self.config.account_keys_dir, + '/tmp/config/acc/acme-server.org/keys') if __name__ == '__main__': diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 9752c3d04..38fb7ef2d 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,15 +1,70 @@ """Tests for letsencrypt.client.crypto_util.""" +import logging import os import pkg_resources +import shutil +import tempfile import unittest import M2Crypto +import mock RSA256_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') RSA512_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa512_key.pem') +class InitSaveKeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.init_save_key.""" + def setUp(self): + logging.disable(logging.CRITICAL) + self.key_dir = tempfile.mkdtemp('key_dir') + + def tearDown(self): + logging.disable(logging.NOTSET) + shutil.rmtree(self.key_dir) + + @classmethod + def _call(cls, key_size, key_dir): + from letsencrypt.client.crypto_util import init_save_key + return init_save_key(key_size, key_dir, 'key-letsencrypt.pem') + + @mock.patch('letsencrypt.client.crypto_util.make_key') + def test_success(self, mock_make): + mock_make.return_value = 'key_pem' + key = self._call(1024, self.key_dir) + self.assertEqual(key.pem, 'key_pem') + self.assertTrue('key-letsencrypt.pem' in key.file) + + @mock.patch('letsencrypt.client.crypto_util.make_key') + def test_key_failure(self, mock_make): + mock_make.side_effect = ValueError + self.assertRaises(ValueError, self._call, 431, self.key_dir) + + +class InitSaveCSRTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.init_save_csr.""" + + def setUp(self): + self.csr_dir = tempfile.mkdtemp('csr_dir') + + def tearDown(self): + shutil.rmtree(self.csr_dir) + + @mock.patch('letsencrypt.client.crypto_util.make_csr') + @mock.patch('letsencrypt.client.crypto_util.le_util.make_or_verify_dir') + def test_it(self, unused_mock_verify, mock_csr): + from letsencrypt.client.crypto_util import init_save_csr + + mock_csr.return_value = ('csr_pem', 'csr_der') + + csr = init_save_csr( + mock.Mock(pem='dummy_key'), 'example.com', self.csr_dir, + 'csr-letsencrypt.pem') + + self.assertEqual(csr.data, 'csr_der') + self.assertTrue('csr-letsencrypt.pem' in csr.file) + class ValidCSRTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.valid_csr.""" diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index c9520e3cf..88461e7d1 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -162,7 +162,11 @@ def main(): # pylint: disable=too-many-branches, too-many-statements sys.exit() if args.revoke or args.rev_cert is not None or args.rev_key is not None: - client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) + # This depends on the renewal config and cannot be completed yet. + zope.component.getUtility(interfaces.IDisplay).notification( + "Revocation is not available with the new Boulder server yet.") + + # client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) sys.exit() if args.rollback > 0: @@ -207,6 +211,9 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # le_util.Key(args.authkey[0], args.authkey[1]) account = client.determine_account(config) + if account is None: + sys.exit(0) + acme = client.Client(config, account, auth, installer) # Validate the key and csr From d6026d757382d5c678071ffb063bca3befc66879 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 27 Apr 2015 13:07:13 -0700 Subject: [PATCH 24/40] Add todo for obtain certificate. --- letsencrypt/client/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 96fcc46b1..183cd6f94 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -95,6 +95,10 @@ class Client(object): :meth:`.register` must be called before :meth:`.obtain_certificate` + .. todo:: This function currently uses the account key for the cert. + This should be changed to an independent key once renewal is sorted + out. + :param set domains: domains to get a certificate :param csr: CSR must contain requested domains, the key used to generate From bdcf8fc91e6e4026af28e26ada55b83b187bb47a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 27 Apr 2015 13:14:39 -0700 Subject: [PATCH 25/40] remove disable pylint from file --- letsencrypt/client/auth_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 32a7d1261..0f2d76653 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -10,7 +10,7 @@ from letsencrypt.client import achallenges from letsencrypt.client import constants from letsencrypt.client import errors -# pylint: disable=too-many-instance-attributes, too-few-public-methods + class AuthHandler(object): """ACME Authorization Handler for a client. From 752b3b687fb3e986fb84b6ebcc490fe47ddb54fe Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 27 Apr 2015 14:59:44 -0700 Subject: [PATCH 26/40] cleanup --- docs/api/client/account.rst | 5 +++++ letsencrypt/acme/messages2_test.py | 6 +++--- letsencrypt/client/interfaces.py | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 docs/api/client/account.rst diff --git a/docs/api/client/account.rst b/docs/api/client/account.rst new file mode 100644 index 000000000..6fad87556 --- /dev/null +++ b/docs/api/client/account.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.account` +--------------------------------- + +.. automodule:: letsencrypt.client.account + :members: diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py index e1d5efe47..33a55dcf3 100644 --- a/letsencrypt/acme/messages2_test.py +++ b/letsencrypt/acme/messages2_test.py @@ -58,7 +58,6 @@ class ConstantTest(unittest.TestCase): self.MockConstant = MockConstant # pylint: disable=invalid-name self.const_a = MockConstant('a') self.const_b = MockConstant('b') - self.const_a_prime = MockConstant('a') def test_to_partial_json(self): self.assertEqual('a', self.const_a.to_partial_json()) @@ -77,11 +76,12 @@ class ConstantTest(unittest.TestCase): self.assertEqual('MockConstant(b)', repr(self.const_b)) def test_equality(self): + const_a_prime = self.MockConstant('a') self.assertFalse(self.const_a == self.const_b) - self.assertTrue(self.const_a == self.const_a_prime) + self.assertTrue(self.const_a == const_a_prime) self.assertTrue(self.const_a != self.const_b) - self.assertFalse(self.const_a != self.const_a_prime) + self.assertFalse(self.const_a != const_a_prime) class RegistrationTest(unittest.TestCase): """Tests for letsencrypt.acme.messages2.Registration.""" diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 48c384172..d432e656e 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -107,6 +107,10 @@ class IConfig(zope.interface.Interface): cert_key_backup = zope.interface.Attribute( "Directory where all certificates and keys are stored. " "Used for easy revocation.") + accounts_dir = zope.interface.Attribute( + "Directory where all account information is stored.") + account_keys_dir = zope.interface.Attribute( + "Directory where all account keys are stored".) rec_token_dir = zope.interface.Attribute( "Directory where all recovery tokens are saved.") key_dir = zope.interface.Attribute("Keys storage.") From 5efdda092212a063cbddab82ef6ceefa790f1da8 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 28 Apr 2015 11:31:04 +0000 Subject: [PATCH 27/40] Fix some of #362 nitpicks --- letsencrypt/acme/messages2.py | 4 ++-- letsencrypt/client/account.py | 9 ++++++++- letsencrypt/client/network2.py | 12 +++++------- letsencrypt/client/tests/account_test.py | 2 +- letsencrypt/client/tests/acme_util.py | 4 ++-- letsencrypt/client/tests/display/ops_test.py | 3 +-- letsencrypt/client/tests/recovery_token_test.py | 3 ++- 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index 86a703155..147b61704 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -164,8 +164,8 @@ class ChallengeBody(ResourceBody): .. todo:: Confusingly, this has a similar name to `.challenges.Challenge`, - as well as `.achallenges.AnnotateChallenge`. Please use names - such as ``challb`` to distinguish instanced of this class from + as well as `.achallenges.AnnotatedChallenge`. Please use names + such as ``challb`` to distinguish instances of this class from ``achall``. :ivar letsencrypt.acme.challenges.Challenge: Wrapped challenge. diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index ed37b6446..6b1e99fc3 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -55,6 +55,8 @@ class Account(object): """URI link for new registrations.""" if self.regr is not None: return self.regr.uri + else: + return None @property def new_authzr_uri(self): # pylint: disable=missing-docstring @@ -65,16 +67,22 @@ class Account(object): # Default: spec says they "may" provide the header # ugh.. acme-spec #93 return "https://%s/acme/new-authz" % self.config.server + else: + return None @property def terms_of_service(self): # pylint: disable=missing-docstring if self.regr is not None: return self.regr.terms_of_service + else: + return None @property def recovery_token(self): # pylint: disable=missing-docstring if self.regr is not None and self.regr.body is not None: return self.regr.body.recovery_token + else: + return None def save(self): """Save account to disk.""" @@ -112,7 +120,6 @@ class Account(object): @classmethod def from_existing_account(cls, config, email=None): """Populate an account from an existing email.""" - config_fp = os.path.join( config.accounts_dir, cls._get_config_filename(email)) return cls._from_config_fp(config, config_fp) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 1793cdb1c..0599bdf5e 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -201,13 +201,10 @@ class Network(object): """ details = ( "mailto:" + account.email if account.email is not None else None, - "tel:" + account.phone if account.phone is not None else None + "tel:" + account.phone if account.phone is not None else None, ) - - contact_tuple = tuple(det for det in details if det is not None) - - account.regr = self.register(contact=contact_tuple) - + account.regr = self.register(contact=tuple( + det for det in details if det is not None)) return account def update_registration(self, regr): @@ -376,7 +373,6 @@ class Network(object): updated_authzr = self._authzr_from_response( response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri) # TODO: check and raise UnexpectedUpdate - return updated_authzr, response def request_issuance(self, csr, authzrs): @@ -534,6 +530,8 @@ class Network(object): """ if certr.cert_chain_uri is not None: return self._get_cert(certr.cert_chain_uri)[1] + else: + return None def revoke(self, certr, when=messages2.Revocation.NOW): """Revoke certificate. diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 2a675c15c..0cb3346c8 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -169,7 +169,7 @@ class SafeEmailTest(unittest.TestCase): addrs = [ "letsencrypt@letsencrypt.org", "tbd.ade@gmail.com", - "abc_def.jdk@hotmail.museum" + "abc_def.jdk@hotmail.museum", ] for addr in addrs: self.assertTrue(self._call(addr), "%s failed." % addr) diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index a81c68aa7..6ae6ef56e 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -75,7 +75,7 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name """Return ChallengeBody from Challenge.""" kwargs = { "chall": chall, - "uri": chall.typ+"_uri", + "uri": chall.typ + "_uri", "status": status, } @@ -129,7 +129,7 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): now = datetime.datetime.now() authz_kwargs.update({ "status": authz_status, - "expires": datetime.datetime(now.year, now.month+1, now.day), + "expires": datetime.datetime(now.year, now.month + 1, now.day), }) else: authz_kwargs.update({ diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 9359e53d0..de5745e8e 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -7,10 +7,10 @@ import unittest import mock import zope.component +from letsencrypt.client import account from letsencrypt.client import le_util from letsencrypt.client.display import util as display_util - class ChooseAuthenticatorTest(unittest.TestCase): """Test choose_authenticator function.""" def setUp(self): @@ -59,7 +59,6 @@ class ChooseAuthenticatorTest(unittest.TestCase): class ChooseAccountTest(unittest.TestCase): """Test choose_account.""" def setUp(self): - from letsencrypt.client import account zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) self.accounts_dir = tempfile.mkdtemp("accounts") diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 4d47f9fbe..0de31a8d0 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -49,7 +49,8 @@ class RecoveryTokenTest(unittest.TestCase): # SHOULD throw an error (OSError other than nonexistent file) self.assertRaises( OSError, self.rec_token.cleanup, - achallenges.RecoveryToken(challb=None, domain="a"+"r"*10000+".com")) + achallenges.RecoveryToken( + challb=None, domain=("a" + "r" * 10000 + ".com"))) def test_perform_stored(self): self.rec_token.store_token("example4.com", 444) From ff569084f86ffdced5a4f96436628a89f804b1c9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 29 Apr 2015 08:19:08 +0000 Subject: [PATCH 28/40] Fix empty email problem, EMAIL_REGEX = re.compile(...), pep8 --- letsencrypt/client/account.py | 18 ++++++++++-------- letsencrypt/client/tests/account_test.py | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index 6b1e99fc3..ef8831fa5 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -35,7 +35,7 @@ class Account(object): # Just make sure we don't get pwned # Make sure that it also doesn't start with a period or have two consecutive # periods <- this needs to be done in addition to the regex - EMAIL_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$" + EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$") def __init__(self, config, key, email=None, phone=None, regr=None): le_util.make_or_verify_dir( @@ -192,13 +192,14 @@ class Account(object): "Enter email address (optional, press Enter to skip)") if code == display_util.OK: - if email == "" or cls.safe_email(email): - email = email if email != "" else None + if not email or cls.safe_email(email): + email = email if email else None le_util.make_or_verify_dir( config.account_keys_dir, 0o700, os.geteuid()) key = crypto_util.init_save_key( - config.rsa_key_size, config.account_keys_dir, email) + config.rsa_key_size, config.account_keys_dir, + cls._get_config_filename(email)) return cls(config, key, email) else: return None @@ -206,7 +207,8 @@ class Account(object): @classmethod def safe_email(cls, email): """Scrub email address before using it.""" - if re.match(cls.EMAIL_REGEX, email): - return bool(not email.startswith(".") and ".." not in email) - logging.warn("Invalid email address.") - return False + if cls.EMAIL_REGEX.match(email): + return not email.startswith(".") and ".." not in email + else: + logging.warn("Invalid email address.") + return False diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 0cb3346c8..269328f27 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -64,14 +64,26 @@ class AccountTest(unittest.TestCase): zope.component.provideUtility(displayer) mock_util().input.return_value = (display_util.OK, self.email) - mock_key.return_value = self.key - acc = account.Account.from_prompts(self.config) + acc = account.Account.from_prompts(self.config) self.assertEqual(acc.email, self.email) self.assertEqual(acc.key, self.key) self.assertEqual(acc.config, self.config) + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") + def test_prompts_empty_email(self, mock_key, mock_util): + displayer = display_util.FileDisplay(sys.stdout) + zope.component.provideUtility(displayer) + + mock_util().input.return_value = (display_util.OK, "") + acc = account.Account.from_prompts(self.config) + self.assertTrue(acc.email is None) + # _get_config_filename | pylint: disable=protected-access + mock_key.assert_called_once_with( + mock.ANY, mock.ANY, acc._get_config_filename(None)) + @mock.patch("letsencrypt.client.account.zope.component.getUtility") def test_prompts_cancel(self, mock_util): # displayer = display_util.FileDisplay(sys.stdout) From 79b0ed5cd3f55c8f1daf07502d3413df7a23782c Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 29 Apr 2015 08:23:42 +0000 Subject: [PATCH 29/40] log act_cert_path --- letsencrypt/client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 183cd6f94..6e98a92bc 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -165,8 +165,8 @@ class Client(object): cert_file.write(certr.body.as_pem()) finally: cert_file.close() - logging.info( - "Server issued certificate; certificate written to %s", cert_path) + logging.info("Server issued certificate; certificate written to %s", + act_cert_path) if certr.cert_chain_uri: # TODO: Except From 3ba8acc57e37489ce62e0854de3d5159fe6e6981 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 29 Apr 2015 09:15:31 +0000 Subject: [PATCH 30/40] Ref to letsencrypt/boulder#128 --- letsencrypt/acme/messages2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index 147b61704..463198d5e 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -18,7 +18,7 @@ class Error(jose.JSONObjectWithFields, Exception): 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', } - # TODO: Boulder omits 'type' and 'instance', spec requires + # TODO: Boulder omits 'type' and 'instance', spec requires, boulder#128 typ = jose.Field('type', omitempty=True) title = jose.Field('title', omitempty=True) detail = jose.Field('detail') From 18a1d01d8f493a8ed577f95959b393a43a0ee56e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 29 Apr 2015 19:49:24 +0000 Subject: [PATCH 31/40] Ref to letsencrypt/boulder#130 --- letsencrypt/client/network2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 0599bdf5e..abe48adb5 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -320,7 +320,7 @@ class Network(object): except KeyError: # TODO: Right now Boulder responds with the authorization resource # instead of a challenge resource... this can be uncommented - # once the error is fixed. + # once the error is fixed (boulder#130). return None # raise errors.NetworkError('"up" Link header missing') challr = messages2.ChallengeResource( From f11f5bca7373eb3339a8b0436902d850e53276a4 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 30 Apr 2015 18:35:49 -0700 Subject: [PATCH 32/40] address comments --- letsencrypt/client/account.py | 55 ++++++++++++++++-------- letsencrypt/client/client.py | 6 ++- letsencrypt/client/configuration.py | 3 +- letsencrypt/client/tests/account_test.py | 20 +++++++-- letsencrypt/client/tests/acme_util.py | 2 +- letsencrypt/scripts/main.py | 24 +++++++---- 6 files changed, 76 insertions(+), 34 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index ef8831fa5..a3afa1015 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -61,14 +61,9 @@ class Account(object): @property def new_authzr_uri(self): # pylint: disable=missing-docstring if self.regr is not None: - if self.regr.new_authzr_uri: - return self.regr.new_authzr_uri - else: - # Default: spec says they "may" provide the header - # ugh.. acme-spec #93 - return "https://%s/acme/new-authz" % self.config.server - else: - return None + return self.regr.new_authzr_uri + + return None @property def terms_of_service(self): # pylint: disable=missing-docstring @@ -94,8 +89,10 @@ class Account(object): self.config.accounts_dir, self._get_config_filename(self.email)) acc_config.initial_comment = [ + "DO NOT EDIT THIS FILE", "Account information for %s under %s" % ( - self._get_config_filename(self.email), self.config.server)] + self._get_config_filename(self.email), self.config.server), + ] acc_config["key"] = self.key.file acc_config["phone"] = self.phone @@ -115,7 +112,7 @@ class Account(object): @classmethod def _get_config_filename(cls, email): - return email if email is not None else "default" + return email if email is not None and email is not "" else "default" @classmethod def from_existing_account(cls, config, email=None): @@ -192,18 +189,38 @@ class Account(object): "Enter email address (optional, press Enter to skip)") if code == display_util.OK: - if not email or cls.safe_email(email): - email = email if email else None - - le_util.make_or_verify_dir( - config.account_keys_dir, 0o700, os.geteuid()) - key = crypto_util.init_save_key( - config.rsa_key_size, config.account_keys_dir, - cls._get_config_filename(email)) - return cls(config, key, email) + try: + return cls.from_email(config, email) + except errors.LetsEncryptClientError: + continue else: return None + @classmethod + def from_email(cls, config, email): + """Generate an account from an email address. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :param str email: Email address + + :raises letsencrypt.client.errors.LetsEncryptClientError: If invalid + email address is given. + + """ + if email == "" or cls.safe_email(email): + email = email if email != "" else None + + le_util.make_or_verify_dir( + config.account_keys_dir, 0o700, os.geteuid()) + key = crypto_util.init_save_key( + config.rsa_key_size, config.account_keys_dir, + cls._get_config_filename(email)) + return cls(config, key, email) + + raise errors.LetsEncryptClientError("Invalid email address.") + @classmethod def safe_email(cls, email): """Scrub email address before using it.""" diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 6e98a92bc..e1a2209da 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -161,8 +161,9 @@ class Client(object): cert_chain_abspath = None cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) # TODO: Except + cert_pem = certr.body.as_pem() try: - cert_file.write(certr.body.as_pem()) + cert_file.write(cert_pem) finally: cert_file.close() logging.info("Server issued certificate; certificate written to %s", @@ -174,8 +175,9 @@ class Client(object): if chain_cert: chain_file, act_chain_path = le_util.unique_file( chain_path, 0o644) + chain_pem = chain_cert.to_pem() try: - chain_file.write(chain_cert.to_pem()) + chain_file.write(chain_pem) finally: chain_file.close() diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index 5474a497d..a0ccdb462 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -58,7 +58,8 @@ class NamespaceConfig(object): def account_keys_dir(self): #pylint: disable=missing-docstring return os.path.join( self.namespace.config_dir, constants.ACCOUNTS_DIR, - self.namespace.server.partition(":")[0], constants.ACCOUNT_KEYS_DIR) + self.namespace.server.replace(':', '-').replace('/', '-'), + constants.ACCOUNT_KEYS_DIR) # TODO: This should probably include the server name @property diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 269328f27..2f7bba60a 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -12,7 +12,6 @@ import zope.component from letsencrypt.acme import messages2 -from letsencrypt.client import account from letsencrypt.client import configuration from letsencrypt.client import errors from letsencrypt.client import le_util @@ -24,6 +23,8 @@ class AccountTest(unittest.TestCase): """Tests letsencrypt.client.account.Account.""" def setUp(self): + from letsencrypt.client import account + logging.disable(logging.CRITICAL) self.accounts_dir = tempfile.mkdtemp("accounts") @@ -60,6 +61,8 @@ class AccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.account.zope.component.getUtility") @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") def test_prompts(self, mock_key, mock_util): + from letsencrypt.client import account + displayer = display_util.FileDisplay(sys.stdout) zope.component.provideUtility(displayer) @@ -86,14 +89,15 @@ class AccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.account.zope.component.getUtility") def test_prompts_cancel(self, mock_util): - # displayer = display_util.FileDisplay(sys.stdout) - # zope.component.provideUtility(displayer) + from letsencrypt.client import account mock_util().input.return_value = (display_util.CANCEL, "") self.assertTrue(account.Account.from_prompts(self.config) is None) def test_save_from_existing_account(self): + from letsencrypt.client import account + self.test_account.save() acc = account.Account.from_existing_account(self.config, self.email) @@ -109,6 +113,8 @@ class AccountTest(unittest.TestCase): self.assertEqual(self.test_account.recovery_token, "recovery_token") def test_partial_properties(self): + from letsencrypt.client import account + partial = account.Account(self.config, self.key) regr_no_authzr_uri = messages2.RegistrationResource( uri="uri", @@ -130,6 +136,8 @@ class AccountTest(unittest.TestCase): "https://letsencrypt-demo.org/acme/new-authz") def test_partial_account_default(self): + from letsencrypt.client import account + partial = account.Account(self.config, self.key) partial.save() @@ -141,6 +149,8 @@ class AccountTest(unittest.TestCase): self.assertEqual(partial.regr, acc.regr) def test_get_accounts(self): + from letsencrypt.client import account + accs = account.Account.get_accounts(self.config) self.assertFalse(accs) @@ -155,10 +165,14 @@ class AccountTest(unittest.TestCase): self.assertEqual(len(accs), 2) def test_get_accounts_no_accounts(self): + from letsencrypt.client import account + self.assertEqual(account.Account.get_accounts( mock.Mock(accounts_dir="non-existant")), []) def test_failed_existing_account(self): + from letsencrypt.client import account + self.assertRaises( errors.LetsEncryptClientError, account.Account.from_existing_account, diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 6ae6ef56e..724b95a2a 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -79,7 +79,7 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name "status": status, } - if status == "valid": + if status == messages2.STATUS_VALID: kwargs.update({"validated": datetime.datetime.now()}) return messages2.ChallengeBody(**kwargs) # pylint: disable=star-args diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 88461e7d1..885872623 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -16,6 +16,7 @@ import zope.interface.verify import letsencrypt +from letsencrypt.client import account from letsencrypt.client import configuration from letsencrypt.client import client from letsencrypt.client import errors @@ -58,7 +59,7 @@ def create_parser(): config_help = lambda name: interfaces.IConfig[name].__doc__ add("-d", "--domains", metavar="DOMAIN", nargs="+") - add("-s", "--server", default="www.letsencrypt-demo.org", + add("-s", "--server", default="www.letsencrypt-demo.org/acme/new-reg", help=config_help("server")) # TODO: we should generate the list of choices from the set of @@ -69,6 +70,8 @@ def create_parser(): add("-k", "--authkey", type=read_file, help="Path to the authorized key file") + add("m", "--email", type=str, + help="Email address used for account registration.") add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", help=config_help("rsa_key_size")) @@ -204,17 +207,22 @@ def main(): # pylint: disable=too-many-branches, too-many-statements sys.exit(0) # Prepare for init of Client - if args.authkey is None: - account = client.determine_account(config) + if args.email is None: + acc = client.determine_account(config) else: - # TODO: Figure out what to do with this - # le_util.Key(args.authkey[0], args.authkey[1]) - account = client.determine_account(config) + try: + # The way to get the default would be args.email = "" + acc = account.from_existing_account(config, args.email) + except errors.LetsEncryptClientError: + try: + acc = account.from_email(config, args.email) + except errors.LetsEncryptClientError: + logging.error("Invalid email address given") - if account is None: + if acc is None: sys.exit(0) - acme = client.Client(config, account, auth, installer) + acme = client.Client(config, acc, auth, installer) # Validate the key and csr client.validate_key_csr(account.key) From 5ba23a6047a5aefbddb55760bea4b04faa31d040 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 30 Apr 2015 20:01:32 -0700 Subject: [PATCH 33/40] fixes to address comments --- letsencrypt/client/account.py | 10 +-- letsencrypt/client/configuration.py | 2 +- letsencrypt/client/interfaces.py | 4 +- letsencrypt/client/network2.py | 4 +- letsencrypt/client/tests/account_test.py | 82 +++++++++---------- .../client/tests/configuration_test.py | 6 +- letsencrypt/scripts/main.py | 40 ++++----- 7 files changed, 75 insertions(+), 73 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index a3afa1015..e40b990a4 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -55,8 +55,8 @@ class Account(object): """URI link for new registrations.""" if self.regr is not None: return self.regr.uri - else: - return None + + return None @property def new_authzr_uri(self): # pylint: disable=missing-docstring @@ -198,7 +198,7 @@ class Account(object): @classmethod def from_email(cls, config, email): - """Generate an account from an email address. + """Generate a new account from an email address. :param config: Configuration :type config: :class:`letsencrypt.client.interfaces.IConfig` @@ -215,8 +215,8 @@ class Account(object): le_util.make_or_verify_dir( config.account_keys_dir, 0o700, os.geteuid()) key = crypto_util.init_save_key( - config.rsa_key_size, config.account_keys_dir, - cls._get_config_filename(email)) + config.rsa_key_size, config.account_keys_dir, + cls._get_config_filename(email)) return cls(config, key, email) raise errors.LetsEncryptClientError("Invalid email address.") diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index a0ccdb462..df00ee3aa 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -52,7 +52,7 @@ class NamespaceConfig(object): def accounts_dir(self): #pylint: disable=missing-docstring return os.path.join( self.namespace.config_dir, constants.ACCOUNTS_DIR, - self.namespace.server.partition(":")[0]) + self.namespace.server.replace(':', '-').replace('/', '-')) @property def account_keys_dir(self): #pylint: disable=missing-docstring diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index d432e656e..1d52d854c 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -95,6 +95,8 @@ class IConfig(zope.interface.Interface): "be trusted in order to avoid further modifications to the client.") authenticator = zope.interface.Attribute( "Authenticator to use for responding to challenges.") + email = zope.interface.Attribute( + "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") config_dir = zope.interface.Attribute("Configuration directory.") @@ -110,7 +112,7 @@ class IConfig(zope.interface.Interface): accounts_dir = zope.interface.Attribute( "Directory where all account information is stored.") account_keys_dir = zope.interface.Attribute( - "Directory where all account keys are stored".) + "Directory where all account keys are stored.") rec_token_dir = zope.interface.Attribute( "Directory where all recovery tokens are saved.") key_dir = zope.interface.Attribute("Keys storage.") diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index abe48adb5..59c1d0a10 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -86,9 +86,7 @@ class Network(object): logging.debug( 'Ignoring wrong Content-Type (%r) for JSON Error', response_ct) - try: - # TODO: This is insufficient or doesn't work as intended. logging.error("Error: %s", jobj) logging.error("Response from server: %s", response.content) raise messages2.Error.from_json(jobj) @@ -328,7 +326,7 @@ class Network(object): body=messages2.ChallengeBody.from_json(response.json())) # TODO: check that challr.uri == response.headers['Location']? if challr.uri != challb.uri: - raise errors.UnexpectedUpdate(challb.uri) + raise errors.UnexpectedUpdate(challr.uri) return challr @classmethod diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 2f7bba60a..2855fe1b0 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -23,7 +23,7 @@ class AccountTest(unittest.TestCase): """Tests letsencrypt.client.account.Account.""" def setUp(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account logging.disable(logging.CRITICAL) @@ -51,7 +51,7 @@ class AccountTest(unittest.TestCase): recovery_token="recovery_token", agreement="agreement") ) - self.test_account = account.Account( + self.test_account = Account( self.config, self.key, self.email, None, self.regr) def tearDown(self): @@ -61,27 +61,34 @@ class AccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.account.zope.component.getUtility") @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") def test_prompts(self, mock_key, mock_util): - from letsencrypt.client import account - - displayer = display_util.FileDisplay(sys.stdout) - zope.component.provideUtility(displayer) + from letsencrypt.client.account import Account mock_util().input.return_value = (display_util.OK, self.email) mock_key.return_value = self.key - acc = account.Account.from_prompts(self.config) + acc = Account.from_prompts(self.config) self.assertEqual(acc.email, self.email) self.assertEqual(acc.key, self.key) self.assertEqual(acc.config, self.config) + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + @mock.patch("letsencrypt.client.account.Account.from_email") + def test_prompts_bad_email(self, mock_from_email, mock_util): + from letsencrypt.client.account import Account + + mock_from_email.side_effect = (errors.LetsEncryptClientError, "acc") + mock_util().input.return_value = (display_util.OK, self.email) + + self.assertEqual(Account.from_prompts(self.config), "acc") + + @mock.patch("letsencrypt.client.account.zope.component.getUtility") @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") def test_prompts_empty_email(self, mock_key, mock_util): - displayer = display_util.FileDisplay(sys.stdout) - zope.component.provideUtility(displayer) + from letsencrypt.client.account import Account mock_util().input.return_value = (display_util.OK, "") - acc = account.Account.from_prompts(self.config) + acc = Account.from_prompts(self.config) self.assertTrue(acc.email is None) # _get_config_filename | pylint: disable=protected-access mock_key.assert_called_once_with( @@ -89,17 +96,23 @@ class AccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.account.zope.component.getUtility") def test_prompts_cancel(self, mock_util): - from letsencrypt.client import account + from letsencrypt.client.account import Account mock_util().input.return_value = (display_util.CANCEL, "") - self.assertTrue(account.Account.from_prompts(self.config) is None) + self.assertTrue(Account.from_prompts(self.config) is None) + + def test_from_email(self): + from letsencrypt.client.account import Account + + self.assertRaises(errors.LetsEncryptClientError, + Account.from_email, self.config, "not_valid...email") def test_save_from_existing_account(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account self.test_account.save() - acc = account.Account.from_existing_account(self.config, self.email) + acc = Account.from_existing_account(self.config, self.email) self.assertEqual(acc.key, self.test_account.key) self.assertEqual(acc.email, self.test_account.email) @@ -113,35 +126,22 @@ class AccountTest(unittest.TestCase): self.assertEqual(self.test_account.recovery_token, "recovery_token") def test_partial_properties(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account - partial = account.Account(self.config, self.key) - regr_no_authzr_uri = messages2.RegistrationResource( - uri="uri", - new_authzr_uri=None, - terms_of_service="terms_of_service", - body=messages2.Registration( - recovery_token="recovery_token", agreement="agreement") - ) - partial2 = account.Account( - self.config, self.key, regr=regr_no_authzr_uri) + partial = Account(self.config, self.key) self.assertTrue(partial.uri is None) self.assertTrue(partial.new_authzr_uri is None) self.assertTrue(partial.terms_of_service is None) self.assertTrue(partial.recovery_token is None) - self.assertEqual( - partial2.new_authzr_uri, - "https://letsencrypt-demo.org/acme/new-authz") - def test_partial_account_default(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account - partial = account.Account(self.config, self.key) + partial = Account(self.config, self.key) partial.save() - acc = account.Account.from_existing_account(self.config) + acc = Account.from_existing_account(self.config) self.assertEqual(partial.key, acc.key) self.assertEqual(partial.email, acc.email) @@ -149,33 +149,33 @@ class AccountTest(unittest.TestCase): self.assertEqual(partial.regr, acc.regr) def test_get_accounts(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account - accs = account.Account.get_accounts(self.config) + accs = Account.get_accounts(self.config) self.assertFalse(accs) self.test_account.save() - accs = account.Account.get_accounts(self.config) + accs = Account.get_accounts(self.config) self.assertEqual(len(accs), 1) self.assertEqual(accs[0].email, self.test_account.email) - acc2 = account.Account(self.config, self.key, "testing_email@gmail.com") + acc2 = Account(self.config, self.key, "testing_email@gmail.com") acc2.save() - accs = account.Account.get_accounts(self.config) + accs = Account.get_accounts(self.config) self.assertEqual(len(accs), 2) def test_get_accounts_no_accounts(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account - self.assertEqual(account.Account.get_accounts( + self.assertEqual(Account.get_accounts( mock.Mock(accounts_dir="non-existant")), []) def test_failed_existing_account(self): - from letsencrypt.client import account + from letsencrypt.client.account import Account self.assertRaises( errors.LetsEncryptClientError, - account.Account.from_existing_account, + Account.from_existing_account, self.config, "non-existant@email.org") class SafeEmailTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/configuration_test.py b/letsencrypt/client/tests/configuration_test.py index 9385dbde3..537e26b91 100644 --- a/letsencrypt/client/tests/configuration_test.py +++ b/letsencrypt/client/tests/configuration_test.py @@ -11,7 +11,7 @@ class NamespaceConfigTest(unittest.TestCase): from letsencrypt.client.configuration import NamespaceConfig namespace = mock.MagicMock( config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar', - server='acme-server.org:443') + server='acme-server.org:443/new') self.config = NamespaceConfig(namespace) def test_proxy_getattr(self): @@ -33,10 +33,10 @@ class NamespaceConfigTest(unittest.TestCase): self.config.cert_key_backup, '/tmp/foo/c/acme-server.org') self.assertEqual(self.config.rec_token_dir, '/r') self.assertEqual( - self.config.accounts_dir, '/tmp/config/acc/acme-server.org') + self.config.accounts_dir, '/tmp/config/acc/acme-server.org-443-new') self.assertEqual( self.config.account_keys_dir, - '/tmp/config/acc/acme-server.org/keys') + '/tmp/config/acc/acme-server.org-443-new/keys') if __name__ == '__main__': diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 885872623..9d30c916c 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -70,7 +70,7 @@ def create_parser(): add("-k", "--authkey", type=read_file, help="Path to the authorized key file") - add("m", "--email", type=str, + add("-m", "--email", type=str, help="Email address used for account registration.") add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", help=config_help("rsa_key_size")) @@ -176,6 +176,24 @@ def main(): # pylint: disable=too-many-branches, too-many-statements client.rollback(args.rollback, config) sys.exit() + # Prepare for init of Client + if args.email is None: + acc = client.determine_account(config) + else: + try: + # The way to get the default would be args.email = "" + # First try existing account + acc = account.Account.from_existing_account(config, args.email) + except errors.LetsEncryptClientError: + try: + # Try to make an account based on the email address + acc = account.Account.from_email(config, args.email) + except errors.LetsEncryptClientError: + sys.exit(1) + + if acc is None: + sys.exit(0) + if not args.tos: display_eula() @@ -206,26 +224,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if not doms: sys.exit(0) - # Prepare for init of Client - if args.email is None: - acc = client.determine_account(config) - else: - try: - # The way to get the default would be args.email = "" - acc = account.from_existing_account(config, args.email) - except errors.LetsEncryptClientError: - try: - acc = account.from_email(config, args.email) - except errors.LetsEncryptClientError: - logging.error("Invalid email address given") - - if acc is None: - sys.exit(0) - acme = client.Client(config, acc, auth, installer) # Validate the key and csr - client.validate_key_csr(account.key) + client.validate_key_csr(acc.key) # This more closely mimics the capabilities of the CLI # It should be possible for reconfig only, install-only, no-install @@ -236,7 +238,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements acme.register() cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, account.key, cert_file, chain_file) + acme.deploy_certificate(doms, acc.key, cert_file, chain_file) if installer is not None: acme.enhance_config(doms, args.redirect) From 4d7f67684d725b2382d13cad23d4cfe53eb9159a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 30 Apr 2015 20:55:26 -0700 Subject: [PATCH 34/40] Move eula to registration --- letsencrypt/client/client.py | 12 ++++++++---- letsencrypt/client/tests/account_test.py | 3 --- letsencrypt/scripts/main.py | 20 +++++++------------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index e1a2209da..d939022ed 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,6 +1,7 @@ """ACME protocol client class and helper functions.""" import logging import os +import pkg_resources import M2Crypto import zope.component @@ -62,8 +63,7 @@ class Client(object): # TODO: Allow for other alg types besides RS256 self.network = network2.Network( - "https://%s/acme/new-reg" % config.server, - jwk.JWKRSA.load(self.account.key.pem)) + "https://" + config.server, jwk.JWKRSA.load(self.account.key.pem)) self.config = config @@ -79,14 +79,18 @@ class Client(object): self.account = self.network.register_from_account(self.account) if self.account.terms_of_service: if not self.config.tos: + # TODO: Replace with self.account.terms_of_service + eula = pkg_resources.resource_string("letsencrypt", "EULA") agree = zope.component.getUtility(interfaces.IDisplay).yesno( - self.account.terms_of_service, "Agree", "Cancel") + eula, "Agree", "Cancel") else: agree = True if agree: self.account.regr = self.network.agree_to_tos(self.account.regr) - # TODO: Handle case where user doesn't agree + else: + # What is the proper response here... + raise errors.LetsEncryptClientError("Must agree to TOS") self.account.save() diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 2855fe1b0..a8005ea9b 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -4,12 +4,9 @@ import mock import os import pkg_resources import shutil -import sys import tempfile import unittest -import zope.component - from letsencrypt.acme import messages2 from letsencrypt.client import configuration diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 9d30c916c..315c93e5f 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -59,7 +59,8 @@ def create_parser(): config_help = lambda name: interfaces.IConfig[name].__doc__ add("-d", "--domains", metavar="DOMAIN", nargs="+") - add("-s", "--server", default="www.letsencrypt-demo.org/acme/new-reg", + add("-s", "--server", + default="www.letsencrypt-demo.org/acme/new-reg", help=config_help("server")) # TODO: we should generate the list of choices from the set of @@ -194,9 +195,6 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if acc is None: sys.exit(0) - if not args.tos: - display_eula() - all_auths = init_auths(config) logging.debug('Initialized authenticators: %s', all_auths.keys()) try: @@ -235,7 +233,11 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # but this code should be safe on all environments. cert_file = None if auth is not None: - acme.register() + if acc.regr is None: + try: + acme.register() + except errors.LetsEncryptClientError: + sys.exit(0) cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: acme.deploy_certificate(doms, acc.key, cert_file, chain_file) @@ -243,14 +245,6 @@ def main(): # pylint: disable=too-many-branches, too-many-statements acme.enhance_config(doms, args.redirect) -def display_eula(): - """Displays the end user agreement.""" - eula = pkg_resources.resource_string("letsencrypt", "EULA") - if not zope.component.getUtility(interfaces.IDisplay).yesno( - eula, "Agree", "Cancel"): - sys.exit(0) - - def read_file(filename): """Returns the given file's contents with universal new line support. From 54955009eb8a54cd4607ea1e6a40d3ce8a534771 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 1 May 2015 10:07:32 +0000 Subject: [PATCH 35/40] constants.CONFIG_DIRS_MODE, fix #362 config dir bug --- letsencrypt/client/constants.py | 3 +++ letsencrypt/client/plugins/apache/configurator.py | 9 ++++++--- letsencrypt/client/plugins/nginx/configurator.py | 9 ++++++--- letsencrypt/client/reverter.py | 8 ++++++-- letsencrypt/scripts/main.py | 6 ++++++ 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 20f735779..239db7373 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -50,6 +50,9 @@ DVSNI_CHALLENGE_PORT = 443 """Port to perform DVSNI challenge.""" +CONFIG_DIRS_MODE = 0o755 +"""Directory mode for ``.IConfig.config_dir`` et al.""" + TEMP_CHECKPOINT_DIR = "temp_checkpoint" """Temporary checkpoint directory (relative to IConfig.work_dir).""" diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index e826c011a..ff3842200 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -934,9 +934,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ uid = os.geteuid() - le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) - le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) - le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) + le_util.make_or_verify_dir( + self.config.config_dir, constants.CONFIG_DIRS_MODE, uid) + le_util.make_or_verify_dir( + self.config.work_dir, constants.CONFIG_DIRS_MODE, uid) + le_util.make_or_verify_dir( + self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid) def get_version(self): """Return version of Apache Server. diff --git a/letsencrypt/client/plugins/nginx/configurator.py b/letsencrypt/client/plugins/nginx/configurator.py index 95ebeab3a..5f49ca8ee 100644 --- a/letsencrypt/client/plugins/nginx/configurator.py +++ b/letsencrypt/client/plugins/nginx/configurator.py @@ -349,9 +349,12 @@ class NginxConfigurator(object): """ uid = os.geteuid() - le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) - le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) - le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) + le_util.make_or_verify_dir( + self.config.work_dir, constants.CONFIG_DIRS_MODE, uid) + le_util.make_or_verify_dir( + self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid) + le_util.make_or_verify_dir( + self.config.config_dir, constants.CONFIG_DIRS_MODE, uid) def get_version(self): """Return version of Nginx Server. diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index ebb85a954..9d739f37e 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -6,9 +6,11 @@ import time import zope.component +from letsencrypt.client import constants from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util + from letsencrypt.client.display import util as display_util @@ -164,7 +166,8 @@ class Reverter(object): unable to add checkpoint """ - le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) + le_util.make_or_verify_dir( + cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) op_fd, existing_filepaths = self._read_and_append( os.path.join(cp_dir, "FILEPATHS")) @@ -305,7 +308,8 @@ class Reverter(object): else: cp_dir = self.config.in_progress_dir - le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) + le_util.make_or_verify_dir( + cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) # Append all new files (that aren't already registered) new_fd = None diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 315c93e5f..ae15f22dd 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -18,10 +18,13 @@ import letsencrypt from letsencrypt.client import account from letsencrypt.client import configuration +from letsencrypt.client import constants from letsencrypt.client import client from letsencrypt.client import errors from letsencrypt.client import interfaces +from letsencrypt.client import le_util from letsencrypt.client import log + from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops as display_ops @@ -177,6 +180,9 @@ def main(): # pylint: disable=too-many-branches, too-many-statements client.rollback(args.rollback, config) sys.exit() + le_util.make_or_verify_dir( + config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) + # Prepare for init of Client if args.email is None: acc = client.determine_account(config) From 0cb012a9fdfcd883568315a3ed8027c436b10bf7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 1 May 2015 10:08:33 +0000 Subject: [PATCH 36/40] Configuration: server_url, server_path --- letsencrypt/client/client.py | 2 +- letsencrypt/client/configuration.py | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index d939022ed..a4e98fa41 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -63,7 +63,7 @@ class Client(object): # TODO: Allow for other alg types besides RS256 self.network = network2.Network( - "https://" + config.server, jwk.JWKRSA.load(self.account.key.pem)) + config.server_url, jwk.JWKRSA.load(self.account.key.pem)) self.config = config diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index df00ee3aa..14c7b23cd 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -28,6 +28,8 @@ class NamespaceConfig(object): zope.interface.implements(interfaces.IConfig) def __init__(self, namespace): + assert not namespace.server.startswith('https://') + assert not namespace.server.startswith('http://') self.namespace = namespace def __getattr__(self, name): @@ -42,24 +44,32 @@ class NamespaceConfig(object): def in_progress_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) + @property + def server_path(self): + """File path based on ``server``.""" + return self.namespace.server.replace('/', os.path.sep) + + @property + def server_url(self): + """Full server URL (including HTTPS scheme).""" + return 'https://' + self.namespace.server + @property def cert_key_backup(self): # pylint: disable=missing-docstring return os.path.join( self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, - self.namespace.server.partition(":")[0]) + self.server_path) @property def accounts_dir(self): #pylint: disable=missing-docstring return os.path.join( - self.namespace.config_dir, constants.ACCOUNTS_DIR, - self.namespace.server.replace(':', '-').replace('/', '-')) + self.namespace.config_dir, constants.ACCOUNTS_DIR, self.server_path) @property def account_keys_dir(self): #pylint: disable=missing-docstring return os.path.join( self.namespace.config_dir, constants.ACCOUNTS_DIR, - self.namespace.server.replace(':', '-').replace('/', '-'), - constants.ACCOUNT_KEYS_DIR) + self.server_path, constants.ACCOUNT_KEYS_DIR) # TODO: This should probably include the server name @property From 9b5ea88abd4987915110f86afb8db60aa079c123 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 1 May 2015 10:09:56 +0000 Subject: [PATCH 37/40] acme-spec#93 solved, ref boulder#130, acme-spec#110 --- letsencrypt/client/network2.py | 1 - letsencrypt/client/tests/network2_test.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 59c1d0a10..16ab80f3b 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -150,7 +150,6 @@ class Network(object): response.links['terms-of-service']['url'] if 'terms-of-service' in response.links else terms_of_service) - # TODO: Consider removing this check based on spec clarifications #93 if new_authzr_uri is None: try: new_authzr_uri = response.links['next']['url'] diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py index 5605cc8aa..5ef9981d4 100644 --- a/letsencrypt/client/tests/network2_test.py +++ b/letsencrypt/client/tests/network2_test.py @@ -285,10 +285,10 @@ class NetworkTest(unittest.TestCase): self.challr.body.update(uri='foo'), chall_response) def test_answer_challenge_missing_next(self): - # TODO: Change once acme-spec #93 is resolved/boulder issue self._mock_post_get() self.assertTrue(self.net.answer_challenge( self.challr.body, challenges.DNSResponse()) is None) + # TODO: boulder#130, acme-spec#110 # self.assertRaises(errors.NetworkError, self.net.answer_challenge, # self.challr.body, challenges.DNSResponse()) From 8c43404015de192cb44eb930682a81744a7d98ec Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 1 May 2015 10:10:04 +0000 Subject: [PATCH 38/40] pep8 --- letsencrypt/client/account.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index e40b990a4..6c0ca9262 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -55,15 +55,15 @@ class Account(object): """URI link for new registrations.""" if self.regr is not None: return self.regr.uri - - return None + else: + return None @property def new_authzr_uri(self): # pylint: disable=missing-docstring if self.regr is not None: return self.regr.new_authzr_uri - - return None + else: + return None @property def terms_of_service(self): # pylint: disable=missing-docstring @@ -112,7 +112,7 @@ class Account(object): @classmethod def _get_config_filename(cls, email): - return email if email is not None and email is not "" else "default" + return email if email is not None and email else "default" @classmethod def from_existing_account(cls, config, email=None): @@ -209,8 +209,8 @@ class Account(object): email address is given. """ - if email == "" or cls.safe_email(email): - email = email if email != "" else None + if not email or cls.safe_email(email): + email = email if email else None le_util.make_or_verify_dir( config.account_keys_dir, 0o700, os.geteuid()) From 0845d82f659f50d318d438229fa84b5618f9270a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 1 May 2015 10:19:48 +0000 Subject: [PATCH 39/40] Update Configuration test --- letsencrypt/client/tests/configuration_test.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/tests/configuration_test.py b/letsencrypt/client/tests/configuration_test.py index 537e26b91..cbbcd57ba 100644 --- a/letsencrypt/client/tests/configuration_test.py +++ b/letsencrypt/client/tests/configuration_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.configuration.""" +import os import unittest import mock @@ -18,6 +19,14 @@ class NamespaceConfigTest(unittest.TestCase): self.assertEqual(self.config.foo, 'bar') self.assertEqual(self.config.work_dir, '/tmp/foo') + def test_server_path(self): + self.assertEqual(['acme-server.org:443', 'new'], + self.config.server_path.split(os.path.sep)) + + def test_server_url(self): + self.assertEqual( + self.config.server_url, 'https://acme-server.org:443/new') + @mock.patch('letsencrypt.client.configuration.constants') def test_dynamic_dirs(self, constants): constants.TEMP_CHECKPOINT_DIR = 't' @@ -30,13 +39,13 @@ class NamespaceConfigTest(unittest.TestCase): self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual( - self.config.cert_key_backup, '/tmp/foo/c/acme-server.org') + self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') self.assertEqual(self.config.rec_token_dir, '/r') self.assertEqual( - self.config.accounts_dir, '/tmp/config/acc/acme-server.org-443-new') + self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new') self.assertEqual( self.config.account_keys_dir, - '/tmp/config/acc/acme-server.org-443-new/keys') + '/tmp/config/acc/acme-server.org:443/new/keys') if __name__ == '__main__': From 5b762e51bee6ef90c46c2347b8731114a2ca703d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 1 May 2015 10:55:26 +0000 Subject: [PATCH 40/40] Update ACME docs (protocol version info) --- letsencrypt/acme/__init__.py | 18 ++++-------------- letsencrypt/acme/messages.py | 23 ++++++++++++++++++++++- letsencrypt/acme/messages2.py | 2 +- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/letsencrypt/acme/__init__.py b/letsencrypt/acme/__init__.py index 95744bbd5..c38cea414 100644 --- a/letsencrypt/acme/__init__.py +++ b/letsencrypt/acme/__init__.py @@ -1,22 +1,12 @@ """ACME protocol implementation. -.. warning:: This module is an implementation of the draft `ACME - protocol version 00`_, and not the latest (as of time of writing), - "RESTified" `ACME protocol version 01`_. It should work with the - server from the `Node.js implementation`_, but will not work with - Boulder_. - +This module is an implementation of the `ACME protocol`_. Latest +supported version: `v02`_. .. _`ACME protocol`: https://github.com/letsencrypt/acme-spec -.. _`ACME protocol version 00`: - https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md +.. _`v02`: + https://github.com/letsencrypt/acme-spec/commit/d328fea2d507deb9822793c512830d827a4150c4 -.. _`ACME protocol version 01`: - https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md - -.. _Boulder: https://github.com/letsencrypt/boulder - -.. _`Node.js implementation`: https://github.com/letsencrypt/node-acme """ diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 412b9fb84..41b7389a7 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -1,4 +1,25 @@ -"""ACME protocol messages.""" +"""ACME protocol v00 messages. + +.. warning:: This module is an implementation of the draft `ACME + protocol version 00`_, and not the "RESTified" `ACME protocol version + 01`_ or later. It should work with `older Node.js implementation`_, + but will definitely not work with Boulder_. It is kept for reference + purposes only. + + +.. _`ACME protocol version 00`: + https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md + +.. _`ACME protocol version 01`: + https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md + +.. _Boulder: https://github.com/letsencrypt/boulder + +.. _`older Node.js implementation`: + https://github.com/letsencrypt/node-acme/commit/f42aa5b7fad4cd2fc289653c4ab14f18052367b3 + + +""" import jsonschema from letsencrypt.acme import challenges diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py index 463198d5e..93f77a3e9 100644 --- a/letsencrypt/acme/messages2.py +++ b/letsencrypt/acme/messages2.py @@ -1,4 +1,4 @@ -"""ACME protocol v02 messages.""" +"""ACME protocol messages.""" from letsencrypt.acme import challenges from letsencrypt.acme import fields from letsencrypt.acme import jose