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/__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/jose/json_util.py b/letsencrypt/acme/jose/json_util.py index 980e11179..ac8cdf7aa 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) @@ -372,17 +372,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_partial_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/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 4755f9b34..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 @@ -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', @@ -19,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') @@ -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.""" @@ -130,10 +132,9 @@ 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 """ - # 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) @@ -163,20 +164,17 @@ class ChallengeBody(ResourceBody): .. 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``. + 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. + 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: """ - __slots__ = ('chall',) uri = jose.Field('uri') status = jose.Field('status', decoder=Status.from_json) @@ -193,6 +191,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. @@ -208,7 +209,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. @@ -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/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py index bfa99d8de..d45aa7f9e 100644 --- a/letsencrypt/acme/messages2_test.py +++ b/letsencrypt/acme/messages2_test.py @@ -79,6 +79,13 @@ class ConstantTest(unittest.TestCase): self.assertEqual('MockConstant(a)', repr(self.const_a)) 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 == const_a_prime) + + self.assertTrue(self.const_a != self.const_b) + self.assertFalse(self.const_a != 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 new file mode 100644 index 000000000..6c0ca9262 --- /dev/null +++ b/letsencrypt/client/account.py @@ -0,0 +1,231 @@ +"""Creates ACME accounts for server.""" +import logging +import os +import re + +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 util as display_util + + +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 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 = 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( + config.accounts_dir, 0o700, os.geteuid()) + self.key = key + self.config = config + if email is not None and self.safe_email(email): + self.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 + 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 + 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.""" + le_util.make_or_verify_dir( + self.config.accounts_dir, 0o700, os.geteuid()) + + acc_config = configobj.ConfigObj() + acc_config.filename = os.path.join( + 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), + ] + + acc_config["key"] = self.key.file + acc_config["phone"] = self.phone + + 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.write() + + @classmethod + def _get_config_filename(cls, email): + return email if email is not None and email else "default" + + @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) + + @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)) + + 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 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 = [] + 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)) + + return accounts + + @classmethod + def from_prompts(cls, config): + """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` + + """ + while True: + code, email = zope.component.getUtility(interfaces.IDisplay).input( + "Enter email address (optional, press Enter to skip)") + + if code == display_util.OK: + try: + return cls.from_email(config, email) + except errors.LetsEncryptClientError: + continue + else: + return None + + @classmethod + def from_email(cls, config, email): + """Generate a new 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 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) + + raise errors.LetsEncryptClientError("Invalid email address.") + + @classmethod + def safe_email(cls, email): + """Scrub email address before using it.""" + 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/achallenges.py b/letsencrypt/client/achallenges.py index cc7c322fe..1a5cf9c8e 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -1,19 +1,20 @@ """Client annotated ACME challenges. -Please use names such as ``achall`` and ``ichall`` (respectively ``achalls`` -and ``ichalls`` for collections) to distiguish from variables "of type" -:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``):: +Please use names such as ``achall`` to distiguish from variables "of type" +: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') - ichall = achallenges.Indexed(achall=achall, index=0) + challb = messages2.ChallengeBody(chall=chall) + achall = achallenges.DNS(chall=challb, domain='example.com') Note, that all annotated challenges act as a proxy objects:: - ichall.token == achall.token == chall.token + achall.token == challb.token """ from letsencrypt.acme import challenges @@ -28,19 +29,22 @@ 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 usfeul for the client. + Wraps around server provided challenge and annotates with data + useful for the client. + + :ivar challb: Wrapped `~.ChallengeBody`. """ + __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 @@ -54,49 +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 - - -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 8e5020dc2..0f2d76653 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -1,19 +1,17 @@ """ACME AuthHandler.""" +import itertools import logging -import sys - -import Crypto.PublicKey.RSA +import time 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 from letsencrypt.client import errors -class AuthHandler(object): # pylint: disable=too-many-instance-attributes +class AuthHandler(object): """ACME Authorization Handler for a client. :ivar dv_auth: Authenticator capable of solving @@ -26,186 +24,212 @@ 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 - :class:`letsencrypt.client.achallenges.Indexed` - :ivar dict cont_c: Keys - domain, Values are Continuity challenges in the - form of :class:`letsencrypt.client.achallenges.Indexed` + :ivar account: Client's Account + :type account: :class:`letsencrypt.client.account.Account` + + :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 + form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge` """ - def __init__(self, dv_auth, cont_auth, network): + def __init__(self, dv_auth, cont_auth, network, account): 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.account = account + self.authzr = dict() - self.dv_c = dict() - self.cont_c = dict() + # List must be used to keep responses straight. + 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, best_effort=False): """Retrieve all authorizations for challenges. - :raises LetsEncryptAuthHandlerError: If unable to retrieve all + :param set domains: Domains for authorization + :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 AuthorizationError: If unable to retrieve all authorizations """ - progress = True - while self.msgs and progress: - progress = False - self._satisfy_challenges() + for domain in domains: + self.authzr[domain] = self.network.request_domain_challenges( + domain, self.account.new_authzr_uri) - delete_list = [] + self._choose_challenges(domains) - for dom in self.domains: - if self._path_satisfied(dom): - self.acme_authorization(dom) - delete_list.append(dom) + # 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...") - # This avoids modifying while iterating over the list - if delete_list: - self._cleanup_state(delete_list) - progress = True + # Send all Responses - this modifies dv_c and cont_c + self._respond(cont_resp, dv_resp, best_effort) - if not progress: - raise errors.LetsEncryptAuthHandlerError( - "Unable to solve challenges for requested names.") + # 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 acme_authorization(self, domain): - """Handle ACME "authorization" phase. - - :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) - - 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 - - """ + def _choose_challenges(self, domains): + """Retrieve necessary challenges to satisfy server.""" logging.info("Performing the following challenges:") - for dom in self.domains: - self.paths[dom] = gen_challenge_path( - self.msgs[dom].challenges, + for dom in domains: + path = gen_challenge_path( + self.authzr[dom].body.challenges, self._get_chall_pref(dom), - self.msgs[dom].combinations) + self.authzr[dom].body.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_cont_c, dom_dv_c = self._challenge_factory( + dom, path) + self.dv_c.extend(dom_dv_c) + self.cont_c.extend(dom_cont_c) + def _solve_challenges(self): + """Get Responses for challenges from authenticators.""" 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:") - logging.critical(str(err)) + except errors.AuthorizationError: + logging.critical("Failure in setting up challenges.") logging.info("Attempting to clean up outstanding challenges...") - for dom in self.domains: - self._cleanup_challenges(dom) + self._cleanup_challenges() + raise - 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...") + return cont_resp, dv_resp - # Assemble Responses - if cont_resp: - self._assign_responses(cont_resp, self.cont_c) - if dv_resp: - self._assign_responses(dv_resp, self.dv_c) + def _respond(self, cont_resp, dv_resp, best_effort): + """Send/Receive confirmation of all challenges. - def _assign_responses(self, flat_list, ichall_dict): - """Assign responses from flat_list back to the Indexed dicts. - - :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 + # TODO: chall_update is a dirty hack to get around acme-spec #105 + chall_update = dict() + 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)) - 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]) + # 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. + + :param dict chall_update: parameter that is updated to hold + 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: + 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, 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 and rounds < max_rounds: + # 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: + 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').""" + 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], achall) + + # 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, 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): + return authzr_challb.status + raise errors.AuthorizationError( + "Target challenge not found in authorization resource") def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -213,45 +237,49 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param str domain: domain for which you are requesting preferences """ + # 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 - def _cleanup_challenges(self, domain): - """Cleanup configuration challenges + def _cleanup_challenges(self, achall_list=None): + """Cleanup challenges. - :param str domain: domain for which to clean up challenges + If achall_list is not provided, cleanup all achallenges. """ - 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) + logging.info("Cleaning up challenges") - def _cleanup_state(self, delete_list): - """Cleanup state after an authorization is received. + 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)] - :param list delete_list: list of domains in str form + 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 verify_authzr_complete(self): + """Verifies that all authorizations have been decided. + + :returns: Whether all authzr are complete + :rtype: bool """ - 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) + 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 @@ -274,54 +302,75 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes cont_chall = [] for index in path: - chall = self.msgs[domain].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[domain]) - elif isinstance(chall, challenges.SimpleHTTPS): - logging.info(" SimpleHTTPS challenge for %s.", domain) - achall = achallenges.SimpleHTTPS( - chall=chall, domain=domain, key=self.authkey[domain]) - elif isinstance(chall, challenges.DNS): - logging.info(" DNS challenge for %s.", domain) - achall = achallenges.DNS(chall=chall, domain=domain) - - elif isinstance(chall, challenges.RecoveryToken): - logging.info(" Recovery Token Challenge for %s.", domain) - 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) - elif isinstance(chall, challenges.ProofOfPossession): - logging.info(" Proof-of-Possession Challenge for %s", domain) - achall = achallenges.ProofOfPossession( - chall=chall, domain=domain) - - else: - raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: %s", chall.typ) - - ichall = achallenges.Indexed(achall=achall, index=index) + achall = challb_to_achall(challb, self.account.key, domain) 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 + return cont_chall, dv_chall -def gen_challenge_path(challs, preferences, combinations): +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. .. 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 @@ -334,18 +383,18 @@ 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.AuthorizationError: If a path cannot be created that satisfies the CA given the preferences and 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 @@ -367,8 +416,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 @@ -380,12 +429,12 @@ 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.AuthorizationError(msg) 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 @@ -398,11 +447,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 @@ -422,11 +471,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 diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 91b271784..a4e98fa41 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,20 +1,22 @@ """ACME protocol client class and helper functions.""" import logging import os -import sys +import pkg_resources -import Crypto.PublicKey.RSA import M2Crypto +import zope.component from letsencrypt.acme import jose -from letsencrypt.acme import messages +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 from letsencrypt.client import errors +from letsencrypt.client import interfaces 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,10 +29,10 @@ 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 account: Account object used for 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 @@ -45,7 +47,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 @@ -55,22 +57,53 @@ class Client(object): :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.network = network.Network(config.server) - self.authkey = authkey + self.account = account_ + self.installer = installer + + # TODO: Allow for other alg types besides RS256 + self.network = network2.Network( + config.server_url, jwk.JWKRSA.load(self.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) + dv_auth, cont_auth, self.network, self.account) else: self.auth_handler = None + def register(self): + """New Registration with the ACME server.""" + 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( + eula, "Agree", "Cancel") + else: + agree = True + + if agree: + self.account.regr = self.network.agree_to_tos(self.account.regr) + else: + # What is the proper response here... + raise errors.LetsEncryptClientError("Must agree to TOS") + + self.account.save() + 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` + + .. 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 this CSR can be different than self.authkey @@ -81,68 +114,43 @@ 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.account.regr is None: + raise errors.LetsEncryptClientError( + "Please register with the ACME server first.") # Perform Challenges/Get Authorizations - self.auth_handler.get_authorizations() + authzr = self.auth_handler.get_authorizations(domains) # 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.account.key, 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) + cert_file, self.account.key.file, self.config) 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, 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 @@ -153,25 +161,36 @@ 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.close() - logging.info( - "Server issued certificate; certificate written to %s", cert_file) + cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) + # TODO: Except + cert_pem = certr.body.as_pem() + try: + cert_file.write(cert_pem) + finally: + cert_file.close() + logging.info("Server issued certificate; certificate written to %s", + act_cert_path) - if certificate_msg.chain: - 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.close() + if certr.cert_chain_uri: + # TODO: Except + chain_cert = self.network.fetch_chain(certr.cert_chain_uri) + 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_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) + # This expects a valid chain file + 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 @@ -295,60 +314,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 list 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. @@ -417,6 +382,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/configuration.py b/letsencrypt/client/configuration.py index 87502ed63..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,11 +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.server_path) + + @property + def account_keys_dir(self): #pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.ACCOUNTS_DIR, + self.server_path, 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..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).""" @@ -61,6 +64,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 e3d0d1c4d..c2b761d59 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,75 @@ 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 + + :returns: Key + :rtype: :class:`letsencrypt.client.le_util.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, os.geteuid()) + 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, csrname="csr-letsencrypt.pem"): + """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. + + :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, csrname), 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..d396e1641 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -43,6 +43,28 @@ def choose_authenticator(auths, errs): return +def choose_account(accounts): + """Choose an account. + + :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 if acc.phone is not None else "") + 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/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/errors.py b/letsencrypt/client/errors.py index 243326b14..f5d9f5f44 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 AuthorizationError(LetsEncryptClientError): + """Authorization error.""" -class LetsEncryptContAuthError(LetsEncryptAuthHandlerError): - """Let's Encrypt Client Authenticator error.""" +class LetsEncryptContAuthError(AuthorizationError): + """Let's Encrypt Continuity Authenticator error.""" -class LetsEncryptDvAuthError(LetsEncryptAuthHandlerError): +class LetsEncryptDvAuthError(AuthorizationError): """Let's Encrypt DV Authenticator error.""" diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 3d3001377..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.") @@ -107,6 +109,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.") @@ -289,13 +295,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. """ diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index c2f535096..16ab80f3b 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 @@ -74,7 +73,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 @@ -88,8 +86,9 @@ class Network(object): logging.debug( 'Ignoring wrong Content-Type (%r) for JSON Error', response_ct) - try: + 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 @@ -167,7 +166,7 @@ class Network(object): 'contact'].default): """Register. - :param contact: Contact list, as accpeted by `.RegistrationResource` + :param contact: Contact list, as accepted by `.Registration` :type contact: `tuple` :returns: Registration Resource. @@ -187,6 +186,24 @@ class Network(object): return regr + def register_from_account(self, account): + """Register with server. + + :param account: Account + :type account: :class:`letsencrypt.client.account.Account` + + :returns: Updated account + :rtype: :class:`letsencrypt.client.account.Account` + + """ + details = ( + "mailto:" + account.email if account.email is not None else None, + "tel:" + account.phone if account.phone is not None else None, + ) + account.regr = self.register(contact=tuple( + det for det in details if det is not None)) + return account + def update_registration(self, regr): """Update registration. @@ -213,6 +230,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` + + """ + return 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: @@ -230,25 +262,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 +287,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. @@ -280,7 +315,11 @@ class Network(object): try: authzr_uri = response.links['up']['url'] except KeyError: - raise errors.NetworkError('"up" Link header missing') + # TODO: Right now Boulder responds with the authorization resource + # instead of a challenge resource... this can be uncommented + # once the error is fixed (boulder#130). + return None + # raise errors.NetworkError('"up" Link header missing') challr = messages2.ChallengeResource( authzr_uri=authzr_uri, body=messages2.ChallengeBody.from_json(response.json())) @@ -289,17 +328,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. @@ -342,7 +370,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): @@ -358,6 +385,7 @@ class Network(object): """ assert authzrs, "Authorizations list is empty" + logging.debug("Requesting issuance...") # TODO: assert len(authzrs) == number of SANs req = messages2.CertificateRequest( @@ -408,7 +436,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 @@ -438,6 +466,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) @@ -489,14 +526,22 @@ class Network(object): """ if certr.cert_chain_uri is not None: - return self._get_cert(certr.cert_chain_uri) + return self._get_cert(certr.cert_chain_uri)[1] + else: + return None 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/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index e6104a559..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. @@ -1006,15 +1009,17 @@ 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/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 9cf0117a0..2780749b5 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? 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( + def _validate_all(self, unused_1, unused_2): + for dom in self.handler.authzr.keys(): + azr = self.handler.authzr[dom] + self.handler.authzr[dom] = acme_util.gen_authzr( + messages2.STATUS_VALID, 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) + [challb.chall for challb in azr.body.challenges], + [messages2.STATUS_VALID]*len(azr.body.challenges), + azr.body.combinations) -# pylint: disable=protected-access -class PathSatisfiedTest(unittest.TestCase): +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 - 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] + # Account and network are mocked... + self.mock_net = mock.MagicMock() + self.handler = AuthHandler( + None, None, self.mock_net, mock.Mock(key="mock_key")) - self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = ["sat", None, None, False] + self.doms = ["0", "1", "2"] + self.handler.authzr[self.doms[0]] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.doms[0], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = ["sat"] + self.handler.authzr[self.doms[1]] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.doms[1], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) - self.handler.paths[dom[3]] = [] - self.handler.responses[dom[3]] = [] + self.handler.authzr[self.doms[2]] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.doms[2], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) - self.handler.paths[dom[4]] = [] - self.handler.responses[dom[4]] = ["respond... sure"] + 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] - for i in xrange(5): - self.assertTrue(self.handler._path_satisfied(dom[i])) + @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) - 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] + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_VALID) - self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = [None, None, None, None] + @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) - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = [None] + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_PENDING) - self.handler.paths[dom[3]] = [0] - self.handler.responses[dom[3]] = [False] + @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) - for i in xrange(3): - self.assertFalse(self.handler._path_satisfied(dom[i])) + @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. @@ -526,42 +304,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, @@ -569,19 +347,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): @@ -640,15 +418,16 @@ 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): @@ -657,6 +436,13 @@ 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( + messages2.STATUS_PENDING, domain, challs, + [messages2.STATUS_PENDING]*len(challs)) + + def gen_path(required, challs): """Generate a combination by picking ``required`` from ``challs``. @@ -670,5 +456,6 @@ def gen_path(required, challs): """ return [challs.index(chall) for chall in required] + if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 63170b517..2a50af93c 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -1,10 +1,57 @@ """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") + account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(account_keys_dir, 0o700) + + self.config = mock.MagicMock( + spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, + account_keys_dir=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): + """Test determine account""" + 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, 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/configuration_test.py b/letsencrypt/client/tests/configuration_test.py index dde1f44cb..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 @@ -10,24 +11,41 @@ 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/new') self.config = NamespaceConfig(namespace) def test_proxy_getattr(self): 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' 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.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.assertEqual( + self.config.account_keys_dir, + '/tmp/config/acc/acme-server.org:443/new/keys') if __name__ == '__main__': diff --git a/letsencrypt/client/tests/continuity_auth_test.py b/letsencrypt/client/tests/continuity_auth_test.py index c1f4a229c..7a2279bcd 100644 --- a/letsencrypt/client/tests/continuity_auth_test.py +++ b/letsencrypt/client/tests/continuity_auth_test.py @@ -21,14 +21,14 @@ class PerformTest(unittest.TestCase): name="rec_token_perform", side_effect=gen_client_resp) def test_rec_token1(self): - token = achallenges.RecoveryToken(chall=None, domain="0") + token = achallenges.RecoveryToken(challb=None, domain="0") responses = self.auth.perform([token]) self.assertEqual(responses, ["RecoveryToken0"]) def test_rec_token5(self): tokens = [] for i in xrange(5): - tokens.append(achallenges.RecoveryToken(chall=None, domain=str(i))) + tokens.append(achallenges.RecoveryToken(challb=None, domain=str(i))) responses = self.auth.perform(tokens) @@ -39,7 +39,7 @@ class PerformTest(unittest.TestCase): def test_unexpected(self): self.assertRaises( errors.LetsEncryptContAuthError, self.auth.perform, [ - achallenges.DVSNI(chall=None, domain="0", key="invalid_key")]) + achallenges.DVSNI(challb=None, domain="0", key="invalid_key")]) def test_chall_pref(self): self.assertEqual( @@ -58,8 +58,8 @@ class CleanupTest(unittest.TestCase): self.auth.rec_token.cleanup = self.mock_cleanup def test_rec_token2(self): - token1 = achallenges.RecoveryToken(chall=None, domain="0") - token2 = achallenges.RecoveryToken(chall=None, domain="1") + token1 = achallenges.RecoveryToken(challb=None, domain="0") + token2 = achallenges.RecoveryToken(challb=None, domain="1") self.auth.cleanup([token1, token2]) @@ -67,8 +67,8 @@ class CleanupTest(unittest.TestCase): [mock.call(token1), mock.call(token2)]) def test_unexpected(self): - token = achallenges.RecoveryToken(chall=None, domain="0") - unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key") + token = achallenges.RecoveryToken(challb=None, domain="0") + unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key") self.assertRaises(errors.LetsEncryptContAuthError, self.auth.cleanup, [token, unexpected]) diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index b950c1e65..a36b96c99 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,9 +1,13 @@ """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( @@ -12,6 +16,57 @@ RSA512_KEY = pkg_resources.resource_string( 'letsencrypt.acme.jose', os.path.join('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/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 11edfe4e3..de5745e8e 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -1,13 +1,16 @@ """Test letsencrypt.client.display.ops.""" +import os import sys +import tempfile 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): @@ -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): + 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@g.com") + self.acc2 = account.Account( + self.config, self.key, "email2@g.com", "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): diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 69dea26ea..42c948c79 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -128,9 +128,9 @@ class NcursesDisplayTest(DisplayT): self.displayer.checklist("message", self.tags) choices = [ - (self.tags[0], "", False), - (self.tags[1], "", False), - (self.tags[2], "", False) + (self.tags[0], "", True), + (self.tags[1], "", True), + (self.tags[2], "", True), ] mock_checklist.assert_called_with( "message", width=display_util.WIDTH, height=display_util.HEIGHT, diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py index c4b3461ad..195788d66 100644 --- a/letsencrypt/client/tests/network2_test.py +++ b/letsencrypt/client/tests/network2_test.py @@ -9,12 +9,13 @@ import M2Crypto import mock import requests -from letsencrypt.client import errors - from letsencrypt.acme import challenges from letsencrypt.acme import jose from letsencrypt.acme import messages2 +from letsencrypt.client import account +from letsencrypt.client import errors + CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( pkg_resources.resource_string( @@ -196,6 +197,30 @@ class NetworkTest(unittest.TestCase): self.assertRaises( errors.NetworkError, self.net.register, self.regr.body) + def test_register_from_account(self): + self.net.register = mock.Mock() + acc = account.Account( + mock.Mock(accounts_dir='mock_dir'), 'key', + email='cert-admin@example.com', phone='+12025551212') + + self.net.register_from_account(acc) + + self.net.register.assert_called_with(contact=self.contact) + + def test_register_from_account_partial_info(self): + self.net.register = mock.Mock() + acc = account.Account( + mock.Mock(accounts_dir='mock_dir'), 'key', + email='cert-admin@example.com') + acc2 = account.Account(mock.Mock(accounts_dir='mock_dir'), 'key') + + self.net.register_from_account(acc) + self.net.register.assert_called_with( + contact=('mailto:cert-admin@example.com',)) + + self.net.register_from_account(acc2) + self.net.register.assert_called_with(contact=()) + def test_update_registration(self): self.response.headers['Location'] = self.regr.uri self.response.json.return_value = self.regr.body.to_json() @@ -208,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 @@ -217,7 +248,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 @@ -255,16 +286,11 @@ class NetworkTest(unittest.TestCase): def test_answer_challenge_missing_next(self): self._mock_post_get() - self.assertRaises(errors.NetworkError, self.net.answer_challenge, - self.challr.body, challenges.DNSResponse()) - - def test_answer_challenges(self): - self.net.answer_challenge = mock.MagicMock() - self.assertEqual( - [self.net.answer_challenge( - self.challr.body, challenges.DNSResponse())], - self.net.answer_challenges( - [self.challr.body], [challenges.DNSResponse()])) + 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()) def test_retry_after_date(self): self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' @@ -435,7 +461,8 @@ class NetworkTest(unittest.TestCase): def test_fetch_chain(self): # pylint: disable=protected-access self.net._get_cert = mock.MagicMock() - self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri), + self.net._get_cert.return_value = ("response", "certificate") + self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri)[1], self.net.fetch_chain(self.certr)) def test_fetch_chain_no_up_link(self): diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 01ba78d72..0de31a8d0 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -39,22 +39,24 @@ class RecoveryTokenTest(unittest.TestCase): self.assertFalse(self.rec_token.requires_human("example3.com")) self.rec_token.cleanup(achallenges.RecoveryToken( - chall=None, domain="example3.com")) + challb=challenges.RecoveryToken(), domain="example3.com")) self.assertTrue(self.rec_token.requires_human("example3.com")) # Shouldn't throw an error self.rec_token.cleanup(achallenges.RecoveryToken( - chall=None, domain="example4.com")) + challb=None, domain="example4.com")) # SHOULD throw an error (OSError other than nonexistent file) self.assertRaises( OSError, self.rec_token.cleanup, - achallenges.RecoveryToken(chall=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) response = self.rec_token.perform( - achallenges.RecoveryToken(chall=None, domain="example4.com")) + achallenges.RecoveryToken( + challb=challenges.RecoveryToken(), domain="example4.com")) self.assertEqual( response, challenges.RecoveryTokenResponse(token="444")) @@ -63,12 +65,14 @@ class RecoveryTokenTest(unittest.TestCase): def test_perform_not_stored(self, mock_input): mock_input().input.side_effect = [(0, "555"), (1, "000")] response = self.rec_token.perform( - achallenges.RecoveryToken(chall=None, domain="example5.com")) + achallenges.RecoveryToken( + challb=challenges.RecoveryToken(), domain="example5.com")) self.assertEqual( response, challenges.RecoveryTokenResponse(token="555")) response = self.rec_token.perform( - achallenges.RecoveryToken(chall=None, domain="example6.com")) + achallenges.RecoveryToken( + challb=challenges.RecoveryToken(), domain="example6.com")) self.assertTrue(response is None) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 9da8c30b0..ae15f22dd 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -16,12 +16,15 @@ import zope.interface.verify 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 @@ -59,7 +62,8 @@ 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/acme/new-reg", help=config_help("server")) # TODO: we should generate the list of choices from the set of @@ -70,6 +74,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")) @@ -93,7 +99,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,15 +169,37 @@ 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: client.rollback(args.rollback, config) sys.exit() - if not args.eula: - display_eula() + 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) + 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) all_auths = init_auths(config) logging.debug('Initialized authenticators: %s', all_auths.keys()) @@ -200,16 +228,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if not doms: sys.exit(0) - # Prepare for init of Client - if args.authkey is None: - authkey = client.init_key(args.rsa_key_size, config.key_dir) - else: - authkey = le_util.Key(args.authkey[0], args.authkey[1]) - - acme = client.Client(config, authkey, auth, installer) + acme = client.Client(config, acc, auth, installer) # Validate the key and csr - client.validate_key_csr(authkey) + 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 @@ -217,21 +239,18 @@ 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: + 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, authkey, 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) -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. diff --git a/setup.py b/setup.py index a4c7f7683..d8728f5e2 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', 'ConfArgParse', + 'configobj', 'jsonschema', 'mock', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 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)