diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst index dfa1edad6..e69826cf9 100644 --- a/docs/api/client/apache.rst +++ b/docs/api/client/apache.rst @@ -10,6 +10,12 @@ .. automodule:: letsencrypt.client.apache.configurator :members: +:mod:`letsencrypt.client.apache.dvsni` +============================================= + +.. automodule:: letsencrypt.client.apache.dvsni + :members: + :mod:`letsencrypt.client.apache.obj` ==================================== diff --git a/docs/api/client/auth_handler.rst b/docs/api/client/auth_handler.rst new file mode 100644 index 000000000..e84745d1e --- /dev/null +++ b/docs/api/client/auth_handler.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.auth_handler` +-------------------------------- + +.. automodule:: letsencrypt.client.auth_handler + :members: diff --git a/docs/api/client/client_authenticator.rst b/docs/api/client/client_authenticator.rst new file mode 100644 index 000000000..a9050de50 --- /dev/null +++ b/docs/api/client/client_authenticator.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.client_authenticator` +-------------------------------- + +.. automodule:: letsencrypt.client.client_authenticator + :members: diff --git a/docs/api/client/recovery_token.rst b/docs/api/client/recovery_token.rst new file mode 100644 index 000000000..cc37e036d --- /dev/null +++ b/docs/api/client/recovery_token.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.recovery_token` +-------------------------------------------------- + +.. automodule:: letsencrypt.client.recovery_token + :members: diff --git a/docs/api/client/recovery_token_challenge.rst b/docs/api/client/recovery_token_challenge.rst deleted file mode 100644 index 68fbdc6e1..000000000 --- a/docs/api/client/recovery_token_challenge.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.recovery_token_challenge` --------------------------------------------------- - -.. automodule:: letsencrypt.client.recovery_token_challenge - :members: diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 3cc9d09a6..2ce39a73b 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -30,9 +30,10 @@ IN_PROGRESS_DIR = os.path.join(BACKUP_DIR, "IN_PROGRESS/") """Directory used before a permanent checkpoint is finalized""" CERT_KEY_BACKUP = os.path.join(WORK_DIR, "keys-certs/") -"""Directory where all certificates/keys are stored. +"""Directory where all certificates/keys are stored. Used for easy revocation""" -Used for easy revocation""" +REV_TOKENS_DIR = os.path.join(WORK_DIR, "revocation_tokens/") +"""Directory where all revocation tokens are saved.""" KEY_DIR = os.path.join(SERVER_ROOT, "ssl/") """Where all keys should be stored""" @@ -47,9 +48,6 @@ OPTIONS_SSL_CONF = os.path.join(CONFIG_DIR, "options-ssl.conf") LE_VHOST_EXT = "-le-ssl.conf" """Let's Encrypt SSL vhost configuration extension""" -APACHE_CHALLENGE_CONF = os.path.join(CONFIG_DIR, "le_dvsni_cert_challenge.conf") -"""Temporary file for challenge virtual hosts""" - CERT_PATH = CERT_DIR + "cert-letsencrypt.pem" """Let's Encrypt cert file.""" @@ -59,15 +57,15 @@ CHAIN_PATH = CERT_DIR + "chain-letsencrypt.pem" INVALID_EXT = ".acme.invalid" """Invalid Extension""" -# Challenge Information -CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] -"""Challenge Preferences Dict for currently supported challenges""" - EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] """Mutually Exclusive Challenges - only solve 1""" -CONFIG_CHALLENGES = frozenset(["dvsni", "simpleHttps"]) -"""These are challenges that must be solved by a Configurator object""" +DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) +"""These are challenges that must be solved by an Authenticator object""" + +CLIENT_CHALLENGES = frozenset( + ["recoveryToken", "recoveryContact", "proofOfPossession"]) +"""These are challenges that are handled by client.py""" # Challenge Constants S_SIZE = 32 diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 0b1f576dc..a826c6756 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1,9 +1,7 @@ """Apache Configuration based off of Augeas Configurator.""" import logging import os -import pkg_resources import re -import shutil import socket import subprocess import sys @@ -17,9 +15,11 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util +from letsencrypt.client.apache import dvsni from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser + # TODO: Augeas sections ie. , beginning and closing # tags need to be the same case, otherwise Augeas doesn't recognize them. # This is not able to be completely remedied by regular expressions because @@ -117,6 +117,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts = self.get_virtual_hosts() # Add name_server association dict self.assoc = dict() + # Add number of outstanding challenges + self.chall_out = 0 # Enable mod_ssl if it isn't already enabled # This is Let's Encrypt... we enable mod_ssl on initialization :) @@ -125,11 +127,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() - # Note: initialization doesn't check to see if the config is correct - # by Apache's standards. This should be done by the client (client.py) - # if it is desired. There may be instances where correct configuration - # isn't required on startup. - def deploy_cert(self, vhost, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. @@ -804,7 +801,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def is_site_enabled(self, avail_fp): """Checks to see if the given site is enabled. - .. todo:: fix hardcoded sites-enabled + .. todo:: fix hardcoded sites-enabled, check os.path.samefile :param str avail_fp: Complete file path of available site @@ -814,7 +811,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ enabled_dir = os.path.join(self.parser.root, "sites-enabled/") for entry in os.listdir(enabled_dir): - if os.path.realpath(enabled_dir + entry) == avail_fp: + if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp: return True return False @@ -929,190 +926,60 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### + # pylint: disable=no-self-use, unused-argument + def get_chall_pref(self, domain): + """Return list of challenge preferences.""" - # TODO: Change list_sni_tuple to namedtuple. Also include key within tuple. - # This allows the keys to be different for each SNI challenge + return ["dvsni"] - def perform(self, chall_dict): + def perform(self, chall_list): """Perform the configuration related challenge. - :param dict chall_dict: Dictionary representing a challenge. + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + :param list chall_list: List of challenges to be + fulfilled by configurator. + + :returns: list of responses. All responses are returned in the same + order as received by the perform function. A None response + indicates the challenge was not perfromed. + :rtype: list """ + self.chall_out += len(chall_list) + responses = [None] * len(chall_list) + apache_dvsni = dvsni.ApacheDvsni(self) - if chall_dict.get("type", "") == 'dvsni': - return self.dvsni_perform(chall_dict) - return None + for i, chall in enumerate(chall_list): + if isinstance(chall, challenge_util.DvsniChall): + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. + apache_dvsni.add_chall(chall, i) - def dvsni_perform(self, chall_dict): - """Perform a DVSNI challenge. - - `chall_dict` composed of: - - list_sni_tuple: - List of tuples with form `(name, r, nonce)`, where - `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) - - dvsni_key: - DVSNI key (:class:`letsencrypt.client.client.Client.Key`) - - :param dict chall_dict: dvsni challenge - see documentation - - """ - # Save any changes to the configuration as a precaution - # About to make temporary changes to the config - self.save() - - # Do weak validation that challenge is of expected type - if not ("list_sni_tuple" in chall_dict and "dvsni_key" in chall_dict): - logging.fatal("Incorrect parameter given to Apache DVSNI challenge") - logging.fatal("Chall dict: %s", chall_dict) - sys.exit(1) - - addresses = [] - default_addr = "*:443" - for tup in chall_dict["list_sni_tuple"]: - vhost = self.choose_virtual_host(tup[0]) - if vhost is None: - logging.error( - "No vhost exists with servername or alias of: %s", tup[0]) - logging.error("No _default_:443 vhost exists") - logging.error("Please specify servernames in the Apache config") - return None - - # TODO - @jdkasten review this code to make sure it makes sense - self.make_server_sni_ready(vhost, default_addr) - - for addr in vhost.addrs: - if "_default_" == addr.get_addr(): - addresses.append([default_addr]) - break - else: - addresses.append(list(vhost.addrs)) - - responses = [] - - # Create all of the challenge certs - for tup in chall_dict["list_sni_tuple"]: - cert_path = self.dvsni_get_cert_file(tup[2]) - self.register_file_creation(cert_path) - s_b64 = challenge_util.dvsni_gen_cert( - cert_path, tup[0], tup[1], tup[2], chall_dict["dvsni_key"]) - - responses.append({"type": "dvsni", "s": s_b64}) - - # Setup the configuration - self.dvsni_mod_config(chall_dict["list_sni_tuple"], - chall_dict["dvsni_key"], - addresses) - - # Save reversible changes and restart the server - self.save("SNI Challenge", True) + 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() + # 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 - def cleanup(self): + def cleanup(self, chall_list): """Revert all challenges.""" + self.chall_out -= len(chall_list) - self.revert_challenge_config() - self.restart() - - # TODO: Variable names - def dvsni_mod_config(self, list_sni_tuple, dvsni_key, - ll_addrs): - """Modifies Apache config files to include challenge vhosts. - - Result: Apache config includes virtual servers for issued challs - - :param list list_sni_tuple: list of tuples with the form - `(addr, y, nonce)`, where `addr` is `str`, `y` is `bytearray`, - and nonce is hex `str` - - :param dvsni_key: DVSNI key - :type dvsni_key: :class:`letsencrypt.client.client.Client.Key` - - :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache.obj.Addr` to apply - - """ - # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY - # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER - # AND TAKEN OUT BEFORE RELEASE, INSTEAD - # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM - - # Check to make sure options-ssl.conf is installed - # pylint: disable=no-member - if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) - - # TODO: Use ip address of existing vhost instead of relying on FQDN - config_text = "\n" - for idx, lis in enumerate(ll_addrs): - config_text += self.get_config_text( - list_sni_tuple[idx][2], lis, dvsni_key.file) - config_text += "\n" - - self.dvsni_conf_include_check(self.parser.loc["default"]) - self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) - - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: - new_conf.write(config_text) - - def dvsni_conf_include_check(self, main_config): - """Adds DVSNI challenge conf file into configuration. - - Adds DVSNI challenge include file if it does not already exist - within mainConfig - - :param str main_config: file path to main user apache config file - - """ - if len(self.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: - # print "Including challenge virtual host(s)" - self.parser.add_dir(parser.get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) - - def get_config_text(self, nonce, ip_addrs, dvsni_key_file): - """Chocolate virtual server configuration text - - :param str nonce: hex form of nonce - :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` - :param str dvsni_key_file: Path to key file - - :returns: virtual host configuration text - :rtype: str - - """ - ips = " ".join(str(i) for i in ip_addrs) - return ("\n" - "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" - "UseCanonicalName on\n" - "SSLStrictSNIVHostCheck on\n" - "\n" - "LimitRequestBody 1048576\n" - "\n" - "Include " + self.parser.loc["ssl_options"] + "\n" - "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + "\n" - "SSLCertificateKeyFile " + dvsni_key_file + "\n" - "\n" - "DocumentRoot " + self.direc["config"] + "challenge_page/\n" - "\n\n") - - def dvsni_get_cert_file(self, nonce): - """Returns standardized name for challenge certificate. - - :param str nonce: hex form of nonce - - :returns: certificate file name - :rtype: str - - """ - return self.direc["work"] + nonce + ".crt" + # If all of the challenges have been finished, clean up everything + if self.chall_out <= 0: + self.revert_challenge_config() + self.restart() def enable_mod(mod_name): diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py new file mode 100644 index 000000000..a9d08da27 --- /dev/null +++ b/letsencrypt/client/apache/dvsni.py @@ -0,0 +1,191 @@ +"""ApacheDVSNI""" +import logging +import os + +from letsencrypt.client import challenge_util +from letsencrypt.client import CONFIG + +from letsencrypt.client.apache import parser + + +class ApacheDvsni(object): + """Class performs DVSNI challenges within the Apache configurator. + + :ivar config: ApacheConfigurator object + :type config: :class:`letsencrypt.client.apache.configurator` + + :ivar dvsni_chall: Data required for challenges. + where DvsniChall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) + :type dvsni_chall: `list` of + :class:`letsencrypt.client.challenge_util.DvsniChall` + + :param list indicies: Meant to hold indices of challenges in a + larger array. ApacheDvsni is capable of solving many challenges + at once which causes an indexing issue within ApacheConfigurator + who must return all responses in order. Imagine ApacheConfigurator + maintaining state about where all of the SimpleHttps Challenges, + Dvsni Challenges belong in the response array. This is an optional + utility. + + :param str challenge_conf: location of the challenge config file + + """ + def __init__(self, config): + self.config = config + self.dvsni_chall = [] + self.indices = [] + self.challenge_conf = os.path.join( + config.direc["config"], "le_dvsni_cert_challenge.conf") + # self.completed = 0 + + def add_chall(self, chall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param chall: DVSNI challenge info + :type chall: :class:`letsencrypt.client.challenge_util.DvsniChall` + + :param int idx: index to challenge in a larger array + + """ + self.dvsni_chall.append(chall) + if idx is not None: + self.indices.append(idx) + + def perform(self): + """Peform a DVSNI challenge.""" + if not self.dvsni_chall: + return None + # Save any changes to the configuration as a precaution + # About to make temporary changes to the config + self.config.save() + + addresses = [] + default_addr = "*:443" + for chall in self.dvsni_chall: + vhost = self.config.choose_virtual_host(chall.domain) + if vhost is None: + logging.error( + "No vhost exists with servername or alias of: %s", + chall.domain) + logging.error("No _default_:443 vhost exists") + logging.error("Please specify servernames in the Apache config") + return None + + # TODO - @jdkasten review this code to make sure it makes sense + self.config.make_server_sni_ready(vhost, default_addr) + + for addr in vhost.addrs: + if "_default_" == addr.get_addr(): + addresses.append([default_addr]) + break + else: + addresses.append(list(vhost.addrs)) + + responses = [] + + # Create all of the challenge certs + for chall in self.dvsni_chall: + cert_path = self.get_cert_file(chall.nonce) + self.config.register_file_creation(cert_path) + s_b64 = challenge_util.dvsni_gen_cert( + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) + + responses.append({"type": "dvsni", "s": s_b64}) + + # Setup the configuration + self._mod_config(addresses) + + # Save reversible changes + self.config.save("SNI Challenge", True) + + return responses + + def _mod_config(self, ll_addrs): + """Modifies Apache config files to include challenge vhosts. + + Result: Apache config includes virtual servers for issued challs + + :param list ll_addrs: list of list of + :class:`letsencrypt.client.apache.obj.Addr` to apply + + """ + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM + + # Check to make sure options-ssl.conf is installed + # pylint: disable=no-member + # if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): + # dist_conf = pkg_resources.resource_filename( + # __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + # shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) + + # TODO: Use ip address of existing vhost instead of relying on FQDN + config_text = "\n" + for idx, lis in enumerate(ll_addrs): + config_text += self._get_config_text( + self.dvsni_chall[idx].nonce, lis, + self.dvsni_chall[idx].key.file) + config_text += "\n" + + self._conf_include_check(self.config.parser.loc["default"]) + self.config.register_file_creation(True, self.challenge_conf) + + with open(self.challenge_conf, 'w') as new_conf: + new_conf.write(config_text) + + def _conf_include_check(self, main_config): + """Adds DVSNI challenge conf file into configuration. + + Adds DVSNI challenge include file if it does not already exist + within mainConfig + + :param str main_config: file path to main user apache config file + + """ + if len(self.config.parser.find_dir( + parser.case_i("Include"), self.challenge_conf)) == 0: + # print "Including challenge virtual host(s)" + self.config.parser.add_dir(parser.get_aug_path(main_config), + "Include", self.challenge_conf) + + def _get_config_text(self, nonce, ip_addrs, dvsni_key_file): + """Chocolate virtual server configuration text + + :param str nonce: hex form of nonce + :param list ip_addrs: addresses of challenged domain + :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :param str dvsni_key_file: Path to key file + + :returns: virtual host configuration text + :rtype: str + + """ + ips = " ".join(str(i) for i in ip_addrs) + return ("\n" + "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" + "UseCanonicalName on\n" + "SSLStrictSNIVHostCheck on\n" + "\n" + "LimitRequestBody 1048576\n" + "\n" + "Include " + self.config.parser.loc["ssl_options"] + "\n" + "SSLCertificateFile " + self.get_cert_file(nonce) + "\n" + "SSLCertificateKeyFile " + dvsni_key_file + "\n" + "\n" + "DocumentRoot " + self.config.direc["config"] + "dvsni_page/\n" + "\n\n") + + def get_cert_file(self, nonce): + """Returns standardized name for challenge certificate. + + :param str nonce: hex form of nonce + + :returns: certificate file name + :rtype: str + + """ + return self.config.direc["work"] + nonce + ".crt" diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index d902bfe19..792257b5a 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -225,6 +225,7 @@ class ApacheParser(object): :rtype: str """ + # Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py regex = "" for letter in clean_fn_match: if letter == '.': diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index ec107d934..4d1caf61d 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -342,7 +342,7 @@ class AugeasConfigurator(object): else: cp_dir = self.direc["progress"] - le_util.make_or_verify_dir(cp_dir) + le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) try: with open(os.path.join(cp_dir, "NEW_FILES"), 'a') as new_fd: for file_path in files: @@ -399,7 +399,7 @@ class AugeasConfigurator(object): else: logging.warn( "File: %s - Could not be found to be deleted\n" - "Program was probably shut down unexpectedly, ") + "LE probably shut down unexpectedly", path) except (IOError, OSError): logging.fatal( "Unable to remove filepaths contained within %s", file_list) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py new file mode 100644 index 000000000..afe85c71a --- /dev/null +++ b/letsencrypt/client/auth_handler.py @@ -0,0 +1,436 @@ +"""ACME AuthHandler.""" +import logging +import sys + +from letsencrypt.client import acme +from letsencrypt.client import CONFIG +from letsencrypt.client import challenge_util +from letsencrypt.client import errors + + +class AuthHandler(object): + """ACME Authorization Handler for a client. + + :ivar dv_auth: Authenticator capable of solving CONFIG.DV_CHALLENGES + :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar client_auth: Authenticator capable of solving CONFIG.CLIENT_CHALLENGES + :type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar network: Network object for sending and receiving authorization + messages + :type network: :class:`letsencrypt.client.network.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.client.Client.Key` + :ivar dict responses: keys: domain, values: list of dict responses + :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.challenge_util.IndexedChall` + :ivar dict client_c: Keys - domain, Values are Client challenges in the form + of :class:`letsencrypt.client.challenge_util.IndexedChall` + + """ + def __init__(self, dv_auth, client_auth, network): + self.dv_auth = dv_auth + self.client_auth = client_auth + self.network = network + + self.domains = [] + self.authkey = dict() + self.responses = dict() + self.msgs = dict() + self.paths = dict() + + self.dv_c = dict() + self.client_c = dict() + + def add_chall_msg(self, domain, msg, authkey): + """Add a challenge message to the AuthHandler. + + :param str domain: domain for authorization + :param dict msg: ACME challenge message + + :param authkey: authorized key for the challenge + :type authkey: :class:`letsencrypt.client.client.Client.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] = ["null"] * len(msg["challenges"]) + self.msgs[domain] = msg + self.authkey[domain] = authkey + + def get_authorizations(self): + """Retreive all authorizations for challenges. + + :raises LetsEncryptAuthHandlerError: If unable to retrieve all + authorizations + + """ + progress = True + while self.msgs and progress: + progress = False + self._satisfy_challenges() + + delete_list = [] + + for dom in self.domains: + if self._path_satisfied(dom): + self.acme_authorization(dom) + delete_list.append(dom) + + # This avoids modifying while iterating over the list + if delete_list: + self._cleanup_state(delete_list) + progress = True + + if not progress: + raise errors.LetsEncryptAuthHandlerError( + "Unable to solve challenges for requested names.") + + def acme_authorization(self, domain): + """Handle ACME "authorization" phase. + + :param str domain: domain that is requesting authorization + + :returns: ACME "authorization" message. + :rtype: dict + + """ + try: + return self.network.send_and_receive_expected( + acme.authorization_request( + self.msgs[domain]["sessionID"], + domain, + self.msgs[domain]["nonce"], + self.responses[domain], + self.authkey[domain].pem), + "authorization") + 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.""" + logging.info("Performing the following challenges:") + for dom in self.domains: + self.paths[dom] = gen_challenge_path( + self.msgs[dom]["challenges"], + self._get_chall_pref(dom), + self.msgs[dom].get("combinations", None)) + + self.dv_c[dom], self.client_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_client = [] + flat_auth = [] + for dom in self.domains: + flat_client.extend(ichall.chall for ichall in self.client_c[dom]) + flat_auth.extend(ichall.chall for ichall in self.dv_c[dom]) + + client_resp = self.client_auth.perform(flat_client) + dv_resp = self.dv_auth.perform(flat_auth) + + logging.info("Ready for verification...") + + # Assemble Responses + self._assign_responses(client_resp, self.client_c) + self._assign_responses(dv_resp, self.dv_c) + + def _assign_responses(self, flat_list, ichall_dict): + """Assign responses from flat_list back to the IndexedChall 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 'client' and 'dv' IndexedChallenges, or their + :class:`letsencrypt.client.challenge_util.IndexedChall` list + + """ + 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 + + def _path_satisfied(self, dom): + """Returns whether a path has been completely satisfied.""" + return all( + None != self.responses[dom][i] and "null" != self.responses[dom][i] + for i in self.paths[dom]) + + def _get_chall_pref(self, domain): + """Return list of challenge preferences. + + :param str domain: domain for which you are requesting preferences + + """ + chall_prefs = [] + chall_prefs.extend(self.client_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 + + :param str domain: domain for which to clean up challenges + + """ + logging.info("Cleaning up challenges...") + self.dv_auth.cleanup(self.dv_c[domain]) + self.client_auth.cleanup(self.client_c[domain]) + + def _cleanup_state(self, delete_list): + """Cleanup state after an authorization is received. + + :param list delete_list: list of domains in str form + + """ + for domain in delete_list: + del self.msgs[domain] + del self.responses[domain] + del self.paths[domain] + + del self.authkey[domain] + + del self.client_c[domain] + del self.dv_c[domain] + + self.domains.remove(domain) + + def _challenge_factory(self, domain, path): + """Construct Namedtuple Challenges + + :param str domain: domain of the enrollee + + :param list path: List of indices from `challenges`. + + :returns: dv_chall, list of + :class:`letsencrypt.client.challenge_util.IndexedChall` + client_chall, list of + :class:`letsencrypt.client.challenge_util.IndexedChall` + :rtype: tuple + + :raises errors.LetsEncryptClientError: If Challenge type is not + recognized + + """ + challenges = self.msgs[domain]["challenges"] + + dv_chall = [] + client_chall = [] + + for index in self.paths[domain]: + chall = challenges[index] + + # Authenticator Challenges + if chall["type"] in CONFIG.DV_CHALLENGES: + dv_chall.append(challenge_util.IndexedChall( + self._construct_dv_chall(chall, domain), index)) + + # Client Challenges + elif chall["type"] in CONFIG.CLIENT_CHALLENGES: + client_chall.append(challenge_util.IndexedChall( + self._construct_client_chall(chall, domain), index)) + + else: + raise errors.LetsEncryptClientError( + "Received unrecognized challenge of type: " + "%s" % chall["type"]) + + return dv_chall, client_chall + + def _construct_dv_chall(self, chall, domain): + """Construct Auth Type Challenges. + + :param dict chall: Single challenge + :param str domain: challenge's domain + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "dvsni": + logging.info(" DVSNI challenge for name %s.", domain) + return challenge_util.DvsniChall( + domain, str(chall["r"]), str(chall["nonce"]), + self.authkey[domain]) + + elif chall["type"] == "simpleHttps": + logging.info(" SimpleHTTPS challenge for name %s.", domain) + return challenge_util.SimpleHttpsChall( + domain, str(chall["token"]), self.authkey[domain]) + + elif chall["type"] == "dns": + logging.info(" DNS challenge for name %s.", domain) + return challenge_util.DnsChall( + domain, str(chall["token"]), self.authkey[domain]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Auth Challenge: %s" % chall["type"]) + + def _construct_client_chall(self, chall, domain): + """Construct Client Type Challenges. + + :param dict chall: Single challenge + :param str domain: challenge's domain + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "recoveryToken": + logging.info(" Recovery Token Challenge for name: %s.", domain) + return challenge_util.RecTokenChall(domain) + + elif chall["type"] == "recoveryContact": + logging.info(" Recovery Contact Challenge for name: %s.", domain) + return challenge_util.RecContactChall( + domain, + chall.get("activationURL", None), + chall.get("successURL", None), + chall.get("contact", None)) + + elif chall["type"] == "proofOfPossession": + logging.info(" Proof-of-Possession Challenge for name: " + "%s", domain) + return challenge_util.PopChall( + domain, chall["alg"], chall["nonce"], chall["hints"]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Client Challenge: %s" % chall["type"]) + +def gen_challenge_path(challenges, preferences, combos=None): + """Generate a plan to get authority over the identity. + + .. todo:: Make sure that the challenges are feasible... + Example: Do you have the recovery key? + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param list preferences: List of challenge preferences for domain + + :param combos: A collection of sets of challenges from ACME + "challenge" server message ("combinations"), each of which would + be sufficient to prove possession of the identifier. + :type combos: list or None + + :returns: List of indices from `challenges`. + :rtype: list + + """ + if combos: + return _find_smart_path(challenges, preferences, combos) + else: + return _find_dumb_path(challenges, preferences) + + +def _find_smart_path(challenges, preferences, combos): + """Find challenge path with server hints. + + Can be called if combinations is included. Function uses a simple + ranking system to choose the combo with the lowest cost. + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param combos: A collection of sets of challenges from ACME + "challenge" server message ("combinations"), each of which would + be sufficient to prove possession of the identifier. + :type combos: list or None + + :returns: List of indices from `challenges`. + :rtype: list + + """ + chall_cost = {} + max_cost = 0 + for i, chall in enumerate(preferences): + chall_cost[chall] = i + max_cost += i + + best_combo = [] + # Set above completing all of the available challenges + best_combo_cost = max_cost + 1 + + combo_total = 0 + for combo in combos: + for challenge_index in combo: + combo_total += chall_cost.get(challenges[ + challenge_index]["type"], max_cost) + if combo_total < best_combo_cost: + best_combo = combo + best_combo_cost = combo_total + combo_total = 0 + + if not best_combo: + logging.fatal("Client does not support any combination of " + "challenges to satisfy ACME server") + sys.exit(22) + + return best_combo + +def _find_dumb_path(challenges, preferences): + """Find challenge path without server hints. + + Should be called if the combinations hint is not included by the + server. This function returns the best path that does not contain + multiple mutually exclusive challenges. + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param list preferences: A list of preferences representing the + challenge type found within the ACME spec. Each challenge type + can only be listed once. + + :returns: List of indices from `challenges`. + :rtype: list + + """ + # Add logic for a crappy server + # Choose a DV + path = [] + assert len(preferences) == len(set(preferences)) + for pref_c in preferences: + for i, offered_challenge in enumerate(challenges): + if (pref_c == offered_challenge["type"] and + is_preferred(offered_challenge["type"], path)): + path.append((i, offered_challenge["type"])) + + return [i for (i, _) in path] + +def is_preferred(offered_challenge_type, path): + """Return whether or not the challenge is preferred in path.""" + for _, challenge_type in path: + for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES: + # Second part is in case we eventually allow multiple names + # to be challenges at the same time + if (challenge_type in mutually_exclusive and + offered_challenge_type in mutually_exclusive and + challenge_type != offered_challenge_type): + return False + + return True diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py deleted file mode 100644 index b2eb33c53..000000000 --- a/letsencrypt/client/challenge.py +++ /dev/null @@ -1,117 +0,0 @@ -"""ACME challenge.""" -import logging -import sys - -from letsencrypt.client import CONFIG - - -def gen_challenge_path(challenges, combos=None): - """Generate a plan to get authority over the identity. - - .. todo:: Make sure that the challenges are feasible... - Example: Do you have the recovery key? - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param combos: A collection of sets of challenges from ACME - "challenge" server message ("combinations"), each of which would - be sufficient to prove possession of the identifier. - :type combos: list or None - - :returns: List of indices from `challenges`. - :rtype: list - - """ - if combos: - return _find_smart_path(challenges, combos) - else: - return _find_dumb_path(challenges) - - -def _find_smart_path(challenges, combos): - """Find challenge path with server hints. - - Can be called if combinations is included. Function uses a simple - ranking system to choose the combo with the lowest cost. - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param combos: A collection of sets of challenges from ACME - "challenge" server message ("combinations"), each of which would - be sufficient to prove possession of the identifier. - :type combos: list or None - - :returns: List of indices from `challenges`. - :rtype: list - - """ - chall_cost = {} - max_cost = 0 - for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES): - chall_cost[chall] = i - max_cost += i - - best_combo = [] - # Set above completing all of the available challenges - best_combo_cost = max_cost + 1 - - combo_total = 0 - for combo in combos: - for challenge_index in combo: - combo_total += chall_cost.get(challenges[ - challenge_index]["type"], max_cost) - if combo_total < best_combo_cost: - best_combo = combo - best_combo_cost = combo_total - combo_total = 0 - - if not best_combo: - logging.fatal("Client does not support any combination of " - "challenges to satisfy ACME server") - sys.exit(22) - - return best_combo - - -def _find_dumb_path(challenges): - """Find challenge path without server hints. - - Should be called if the combinations hint is not included by the - server. This function returns the best path that does not contain - multiple mutually exclusive challenges. - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :returns: List of indices from `challenges`. - :rtype: list - - """ - # Add logic for a crappy server - # Choose a DV - path = [] - for pref_c in CONFIG.CHALLENGE_PREFERENCES: - for i, offered_challenge in enumerate(challenges): - if (pref_c == offered_challenge["type"] and - is_preferred(offered_challenge["type"], path)): - path.append((i, offered_challenge["type"])) - - return [i for (i, _) in path] - - -def is_preferred(offered_challenge_type, path): - for _, challenge_type in path: - for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES: - # Second part is in case we eventually allow multiple names - # to be challenges at the same time - if (challenge_type in mutually_exclusive and - offered_challenge_type in mutually_exclusive and - challenge_type != offered_challenge_type): - return False - - return True diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 26266cda1..aef0d27e8 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -1,4 +1,5 @@ """Challenge specific utility functions.""" +import collections import hashlib from Crypto import Random @@ -7,6 +8,21 @@ from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import le_util +# Authenticator Challenges +DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key") +SimpleHttpsChall = collections.namedtuple( + "SimpleHttpsChall", "domain, token, key") +DnsChall = collections.namedtuple("DnsChall", "domain, token, key") + +# Client Challenges +RecContactChall = collections.namedtuple( + "RecContactChall", "domain, a_url, s_url, contact") +RecTokenChall = collections.namedtuple("RecTokenChall", "domain") +PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") + +# Helper Challenge Wrapper - Can be used to maintain the proper position of +# the response within a larger challenge list +IndexedChall = collections.namedtuple("IndexedChall", "chall, index") # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8750d9526..117dd143e 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -12,7 +12,8 @@ import M2Crypto import zope.component from letsencrypt.client import acme -from letsencrypt.client import challenge +from letsencrypt.client import auth_handler +from letsencrypt.client import client_authenticator from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -38,27 +39,40 @@ class Client(object): :ivar authkey: Authorization Key :type authkey: :class:`letsencrypt.client.client.Client.Key` - :ivar auth: Object that supports the IAuthenticator interface. - :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + :ivar auth_handler: Object that supports the IAuthenticator interface. + auth_handler contains both a dv_authenticator and a client_authenticator + :type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler` :ivar installer: Object supporting the IInstaller interface. :type installer: :class:`letsencrypt.client.interfaces.IInstaller` """ + zope.interface.implements(interfaces.IAuthenticator) + Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") - def __init__(self, server, names, authkey, auth, installer): - """Initialize a client.""" + def __init__(self, server, names, authkey, dv_auth, installer): + """Initialize a client. + + :param str server: CA server to contact + :param dv_auth: IAuthenticator Interface that can solve the + CONFIG.DV_CHALLENGES + :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + """ self.network = network.Network(server) self.names = names self.authkey = authkey sanity_check_names([server] + names) - self.auth = auth self.installer = installer + client_auth = client_authenticator.ClientAuthenticator(server) + self.auth_handler = auth_handler.AuthHandler( + dv_auth, client_auth, self.network) + def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, chain_path=CONFIG.CHAIN_PATH): @@ -76,13 +90,12 @@ class Client(object): """ # Request Challenges - challenge_msg = self.acme_challenge() + for name in self.names: + self.auth_handler.add_chall_msg( + name, self.acme_challenge(name), self.authkey) - # Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_msg) - - # Get Authorization - self.acme_authorization(challenge_msg, challenge_objs, responses) + # Perform Challenges/Get Authorizations + self.auth_handler.get_authorizations() # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -95,43 +108,15 @@ class Client(object): return cert_file, chain_file - def acme_challenge(self): + def acme_challenge(self, domain): """Handle ACME "challenge" phase. - .. todo:: Handle more than one domain name in self.names - :returns: ACME "challenge" message. :rtype: dict """ return self.network.send_and_receive_expected( - acme.challenge_request(self.names[0]), "challenge") - - def acme_authorization(self, challenge_msg, chal_objs, responses): - """Handle ACME "authorization" phase. - - :param dict challenge_msg: ACME "challenge" message. - - :param chal_objs: TODO - this will be a new object... - :param responses: TODO - - :returns: ACME "authorization" message. - :rtype: dict - - """ - try: - return self.network.send_and_receive_expected( - acme.authorization_request( - challenge_msg["sessionID"], self.names[0], - challenge_msg["nonce"], responses, self.authkey.pem), - "authorization") - except errors.LetsEncryptClientError as err: - logging.fatal(str(err)) - logging.fatal( - "Failed Authorization procedure - cleaning up challenges") - sys.exit(1) - finally: - self.cleanup_challenges(chal_objs) + acme.challenge_request(domain), "challenge") def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -242,78 +227,6 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, challenges): - """Cleanup configuration challenges - - :param dict challenges: challenges from a challenge message - - """ - logging.info("Cleaning up challenges...") - for chall in challenges: - if chall["type"] in CONFIG.CONFIG_CHALLENGES: - self.auth.cleanup() - else: - # Handle other cleanup if needed - pass - - def verify_identity(self, challenge_msg): - """Verify identity. - - :param dict challenge_msg: ACME "challenge" message. - - :returns: TODO - :rtype: dict - - """ - path = challenge.gen_challenge_path( - challenge_msg["challenges"], challenge_msg.get("combinations", [])) - - logging.info("Performing the following challenges:") - - # Every indices element is a list of integers referring to which - # challenges in the master list the challenge object satisfies - # Single Challenge objects that can satisfy multiple server challenges - # mess up the order of the challenges, thus requiring the indices - challenge_objs, indices = self.challenge_factory( - self.names[0], challenge_msg["challenges"], path) - - responses = ["null"] * len(challenge_msg["challenges"]) - - # Perform challenges - for i, c_obj in enumerate(challenge_objs): - resp = "null" - if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - resp = self.auth.perform(c_obj) - else: - # Handle RecoveryToken type challenges - pass - - self._assign_responses(resp, indices[i], responses) - - logging.info( - "Configured Apache for challenges; waiting for verification...") - - return responses, challenge_objs - - # pylint: disable=no-self-use - def _assign_responses(self, resp, index_list, responses): - """Assign chall_response to appropriate places in response list. - - :param resp: responses from a challenge - :type resp: list of dicts or dict - - :param list index_list: respective challenges resp satisfies - :param list responses: master list of responses - - """ - if isinstance(resp, list): - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] - else: - for index in index_list: - responses[index] = resp - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -391,67 +304,18 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, name, challenges, path): - """ - :param name: TODO - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param list path: List of indices from `challenges`. - - :returns: A pair of TODO - :rtype: tuple - - """ - sni_todo = [] - # Since a single invocation of SNI challenge can satisfy multiple - # challenges. We must keep track of all the challenges it satisfies - sni_satisfies = [] - - challenge_objs = [] - challenge_obj_indices = [] - for index in path: - chall = challenges[index] - - if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", name) - sni_satisfies.append(index) - sni_todo.append((str(name), str(chall["r"]), - str(chall["nonce"]))) - - elif chall["type"] == "recoveryToken": - logging.info("\tRecovery Token Challenge for name: %s.", name) - challenge_obj_indices.append(index) - challenge_objs.append({ - type: "recoveryToken", - }) - - else: - logging.fatal("Challenge not currently supported") - sys.exit(82) - - if sni_todo: - # SNI_Challenge can satisfy many sni challenges at once so only - # one "challenge object" is issued for all sni_challenges - challenge_objs.append({ - "type": "dvsni", - "list_sni_tuple": sni_todo, - "dvsni_key": self.authkey, - }) - challenge_obj_indices.append(sni_satisfies) - logging.debug(sni_todo) - - return challenge_objs, challenge_obj_indices - - -def validate_key_csr(privkey, csr, names): +def validate_key_csr(privkey, csr): """Validate CSR and key files. - Verifies that the client key and csr arguments are valid and - correspond to one another. + Verifies that the client key and csr arguments are valid and correspond to + one another. This does not currently check the names in the CSR. + + :param privkey: Key associated with CSR + :type privkey: :class:`letsencrypt.client.client.Client.Key` + + :param csr: CSR + :type csr: :class:`letsencrypt.client.client.Client.CSR` :raises LetsEncryptClientError: if validation fails diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py new file mode 100644 index 000000000..ac4406b28 --- /dev/null +++ b/letsencrypt/client/client_authenticator.py @@ -0,0 +1,48 @@ +"""Client Authenticator""" +import zope.interface + +from letsencrypt.client import challenge_util +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import recovery_token + +class ClientAuthenticator(object): + """IAuthenticator for CONFIG.CLIENT_CHALLENGES. + + :ivar rec_token: Performs "recoveryToken" challenges + :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` + + """ + zope.interface.implements(interfaces.IAuthenticator) + + # This will have an installer soon for get_key/cert purposes + def __init__(self, server): + """Initialize Client Authenticator. + + :param str server: ACME CA Server + + """ + self.rec_token = recovery_token.RecoveryToken(server) + + # pylint: disable=unused-argument,no-self-use + def get_chall_pref(self, domain): + """Return list of challenge preferences.""" + return ["recoveryToken"] + + def perform(self, chall_list): + """Perform client specific challenges for IAuthenticator""" + responses = [] + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + responses.append(self.rec_token.perform(chall)) + else: + raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + return responses + + def cleanup(self, chall_list): + """Cleanup call for IAuthenticator.""" + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + self.rec_token.cleanup(chall) + else: + raise errors.LetsEncryptClientAuthError("Unexpected Challenge") diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index dddfc5e4e..ec046c0a5 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,8 +5,16 @@ class LetsEncryptClientError(Exception): """Generic Let's Encrypt client error.""" +class LetsEncryptAuthHandlerError(LetsEncryptClientError): + """Let's Encrypt Auth Handler error.""" + + +class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError): + """Let's Encrypt Client Authenticator Error.""" + + class LetsEncryptConfiguratorError(LetsEncryptClientError): - """Let's Encrypt configurator error.""" + """Let's Encrypt Configurator error.""" class LetsEncryptDvsniError(LetsEncryptConfiguratorError): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..be3c6e09f 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,22 +11,36 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def perform(chall_dict): - """Perform the given challenge""" + def get_chall_pref(domain): + """Return list of challenge preferences. - def cleanup(): + :param str domain: Domain for which challenge preferences are sought. + + :returns: list of strings with the most preferred challenges first. + :rtype: list + + """ + def perform(chall_list): + """Perform the given challenge. + + :param list chall_list: List of challenge types defined in client.py + + :returns: List of responses + If the challenge cant be completed... + None - Authenticator can perform challenge, but can't at this time + False - Authenticator will never be able to perform (error) + :rtype: `list` of dicts + + """ + def cleanup(chall_list): """Revert changes and shutdown after challenges complete.""" class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" - def perform(quiet=True): - """Perform the challenge. - - :param bool quiet: TODO - - """ + def perform(): + """Perform the challenge.""" def generate_response(): """Generate response.""" diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index d96dc8c09..08b0f6114 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -62,9 +62,9 @@ def unique_file(default_name, mode=0o777): f_parsed = os.path.splitext(default_name) while 1: try: - fd = os.open( + file_d = os.open( default_name, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) - return os.fdopen(fd, 'w'), default_name + return os.fdopen(file_d, 'w'), default_name except OSError: pass default_name = f_parsed[0] + '_' + str(count) + f_parsed[1] diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index b1548b687..8ee9ae206 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -11,6 +11,9 @@ from letsencrypt.client import acme from letsencrypt.client import errors +logging.getLogger("requests").setLevel(logging.WARNING) + + class Network(object): """Class for communicating with ACME servers. diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py new file mode 100644 index 000000000..2da8a9c3f --- /dev/null +++ b/letsencrypt/client/recovery_token.py @@ -0,0 +1,79 @@ +"""Recovery Token Identifier Validation Challenge.""" +import errno +import os + +import zope.component +# import zope.interface + +from letsencrypt.client import CONFIG +from letsencrypt.client import le_util +from letsencrypt.client import interfaces + + +class RecoveryToken(object): + """Recovery Token Identifier Validation Challenge. + + Based on draft-barnes-acme, section 6.4. + + """ + def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): + self.token_dir = os.path.join(direc, server) + + def perform(self, chall): + """Perform the Recovery Token Challenge. + + :param chall: Recovery Token Challenge + :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + + :returns: response + :rtype: dict + + """ + token_fp = os.path.join(self.token_dir, chall.domain) + if os.path.isfile(token_fp): + with open(token_fp) as token_fd: + return self.generate_response(token_fd.read()) + + cancel, token = zope.component.getUtility( + interfaces.IDisplay).generic_input( + "%s - Input Recovery Token: " % chall.domain) + if cancel != 1: + return self.generate_response(token) + + return None + + def cleanup(self, chall): + """Cleanup the saved recovery token if it exists. + + :param chall: Recovery Token Challenge + :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + + """ + try: + os.remove(os.path.join(self.token_dir, chall.domain)) + except OSError as err: + if err.errno != errno.ENOENT: + raise + + def generate_response(self, token): # pylint: disable=no-self-use + """Generate json response.""" + return { + "type": "recoveryToken", + "token": token, + } + + def requires_human(self, domain): + """Indicates whether or not domain can be auto solved.""" + return not os.path.isfile(os.path.join(self.token_dir, domain)) + + def store_token(self, domain, token): + """Store token for later automatic use. + + :param str domain: domain associated with the token + :param str token: token from authorization + + """ + le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid()) + + with open(os.path.join(self.token_dir, domain), 'w') as token_fd: + token_fd.write(str(token)) diff --git a/letsencrypt/client/recovery_token_challenge.py b/letsencrypt/client/recovery_token_challenge.py deleted file mode 100644 index b10b24da2..000000000 --- a/letsencrypt/client/recovery_token_challenge.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Recovery Token Identifier Validation Challenge. - -.. note:: This challenge has not been implemented into the project yet - -""" -import zope.component -import zope.interface - -from letsencrypt.client import interfaces - - -class RecoveryToken(object): - """Recovery Token Identifier Validation Challenge. - - Based on draft-barnes-acme, section 6.4. - - """ - zope.interface.implements(interfaces.IChallenge) - - def __init__(self): - super(RecoveryToken, self).__init__() - self.token = "" - - def perform(self, quiet=True): - cancel, self.token = zope.component.getUtility( - interfaces.IDisplay).generic_input( - "Please Input Recovery Token: ") - return cancel != 1 - - def cleanup(self): - pass - - def generate_response(self): - return { - "type": "recoveryToken", - "token": self.token, - } diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py new file mode 100644 index 000000000..504009f02 --- /dev/null +++ b/letsencrypt/client/tests/acme_util.py @@ -0,0 +1,112 @@ +"""Class helps construct valid ACME messages for testing.""" +from letsencrypt.client import CONFIG + + +CHALLENGES = { + "simpleHttps": + { + "type": "simpleHttps", + "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA" + }, + "dvsni": + { + "type": "dvsni", + "r": "Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI", + "nonce": "a82d5ff8ef740d12881f6d3c2277ab2e" + }, + "dns": + { + "type": "dns", + "token": "17817c66b60ce2e4012dfad92657527a" + }, + "recoveryContact": + { + "type": "recoveryContact", + "activationURL": "https://example.ca/sendrecovery/a5bd99383fb0", + "successURL": "https://example.ca/confirmrecovery/bb1b9928932", + "contact": "c********n@example.com" + }, + "recoveryTokent": + { + "type": "recoveryToken" + }, + "proofOfPossession": + { + "type": "proofOfPossession", + "alg": "RS256", + "nonce": "eET5udtV7aoX8Xl8gYiZIA", + "hints": { + "jwk": { + "kty": "RSA", + "e": "AQAB", + "n": "KxITJ0rNlfDMAtfDr8eAw...fSSoehDFNZKQKzTZPtQ" + }, + "certFingerprints": [ + "93416768eb85e33adc4277f4c9acd63e7418fcfe", + "16d95b7b63f1972b980b14c20291f3c0d1855d95", + "48b46570d9fc6358108af43ad1649484def0debf" + ], + "subjectKeyIdentifiers": + ["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], + "serialNumbers": [34234239832, 23993939911, 17], + "issuers": [ + "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", + "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure" + ], + "authorizedFor": ["www.example.com", "example.net"] + } + } +} + + +def get_dv_challenges(): + """Returns all auth challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.DV_CHALLENGES] + + +def get_client_challenges(): + """Returns all client challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.CLIENT_CHALLENGES] + + +def get_challenges(): + """Returns all challenges.""" + return [chall for chall in CHALLENGES.itervalues()] + + +def gen_combos(challs): + """Generate natural combinations for challs.""" + dv_chall = [] + renewal_chall = [] + combos = [] + + for i, chall in enumerate(challs): + if chall["type"] in CONFIG.DV_CHALLENGES: + dv_chall.append(i) + else: + renewal_chall.append(i) + + # Gen combos for 1 of each type + for i in range(len(dv_chall)): + for j in range(len(renewal_chall)): + combos.append([i, j]) + + return combos + + +def get_chall_msg(iden, nonce, challenges, combos=None): + """Produce an ACME challenge message.""" + chall_msg = { + "type": "challenge", + "sessionID": iden, + "nonce": nonce, + "challenges": challenges + } + + if combos is None: + return chall_msg + + chall_msg["combinations"] = combos + return chall_msg diff --git a/letsencrypt/client/tests/apache/__init__.py b/letsencrypt/client/tests/apache/__init__.py new file mode 100644 index 000000000..2c0849a3d --- /dev/null +++ b/letsencrypt/client/tests/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Apache Tests""" diff --git a/letsencrypt/client/tests/config_util.py b/letsencrypt/client/tests/apache/config_util.py similarity index 98% rename from letsencrypt/client/tests/config_util.py rename to letsencrypt/client/tests/apache/config_util.py index 691a394f4..ad38818ab 100644 --- a/letsencrypt/client/tests/config_util.py +++ b/letsencrypt/client/tests/apache/config_util.py @@ -17,7 +17,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): work_dir = tempfile.mkdtemp("work") test_configs = pkg_resources.resource_filename( - __name__, "testdata/%s" % test_dir) + "letsencrypt.client.tests", "testdata/%s" % test_dir) shutil.copytree( test_configs, os.path.join(temp_dir, test_dir), symlinks=True) diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py similarity index 76% rename from letsencrypt/client/tests/apache_configurator_test.py rename to letsencrypt/client/tests/apache/configurator_test.py index e1fd718a2..00560c970 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -1,5 +1,6 @@ -"""Test for letsencrypt.client.apache_configurator.""" +"""Test for letsencrypt.client.apache.configurator.""" import os +import pkg_resources import re import shutil import unittest @@ -7,21 +8,22 @@ import unittest import mock import zope.component -from letsencrypt.client import display +from letsencrypt.client import challenge_util +from letsencrypt.client import client from letsencrypt.client import errors from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser -from letsencrypt.client.tests import config_util +from letsencrypt.client.tests.apache import config_util class TwoVhost80Test(unittest.TestCase): """Test two standard well configured HTTP vhosts.""" def setUp(self): - zope.component.provideUtility(display.NcursesDisplay()) + #zope.component.provideUtility(display.NcursesDisplay()) self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( "debian_apache_2_4/two_vhost_80") @@ -159,5 +161,42 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) + @mock.patch("letsencrypt.client.apache.configurator." + "dvsni.ApacheDvsni.perform") + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + def test_perform(self, mock_restart, mock_dvsni_perform): + # Only tests functionality specific to configurator.perform + # Note: As more challenges are offered this will have to be expanded + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + chall1 = challenge_util.DvsniChall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + chall2 = challenge_util.DvsniChall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key) + + dvsni_ret_val = [ + {"type": "dvsni", "s": "randomS1"}, + {"type": "dvsni", "s": "randomS2"} + ] + + mock_dvsni_perform.return_value = dvsni_ret_val + responses = self.config.perform([chall1, chall2]) + + self.assertEqual(mock_dvsni_perform.call_count, 1) + self.assertEqual(responses, dvsni_ret_val) + + self.assertEqual(mock_restart.call_count, 1) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py new file mode 100644 index 000000000..6beb07b37 --- /dev/null +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -0,0 +1,152 @@ +"""Test for letsencrypt.client.apache.dvsni.""" +import os +import pkg_resources +import unittest +import shutil + +import mock +import zope.component + +from letsencrypt.client import challenge_util +from letsencrypt.client import client +from letsencrypt.client import CONFIG + +from letsencrypt.client.tests.apache import config_util + + +class DvsniPerformTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.client.apache import dvsni + #zope.component.provideUtility(display.NcursesDisplay()) + + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") + + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) + + # Final slash is currently important + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + + config = config_util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, self.ssl_options) + + self.sni = dvsni.ApacheDvsni(config) + + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + self.challs = [] + self.challs.append(challenge_util.DvsniChall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key)) + self.challs.append(challenge_util.DvsniChall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key)) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_perform0(self): + resp = self.sni.perform() + self.assertIs(resp, None) + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform1(self, mock_dvsni_gen_cert, mock_restart): + chall = self.challs[0] + self.sni.add_chall(chall) + mock_dvsni_gen_cert.return_value = "randomS1" + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 1) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [ + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key) + ] + + for i in range(len(expected_call_list)): + for j in range(len(expected_call_list[0])): + self.assertEqual(calls[i][0][j], expected_call_list[i][j]) + + self.assertEqual( + len(self.sni.config.parser.find_dir( + "Include", self.sni.challenge_conf)), + 1) + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0]["s"], "randomS1") + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform2(self, mock_dvsni_gen_cert, mock_restart): + for chall in self.challs: + self.sni.add_chall(chall) + + mock_dvsni_gen_cert.side_effect = ["randomS0", "randomS1"] + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 2) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [] + + for chall in self.challs: + expected_call_list.append( + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key)) + + for i in range(len(expected_call_list)): + for j in range(len(expected_call_list[0])): + self.assertEqual(calls[i][0][j], expected_call_list[i][j]) + + self.assertEqual( + len(self.sni.config.parser.find_dir( + "Include", self.sni.challenge_conf)), + 1) + self.assertEqual(len(responses), 2) + for i in range(2): + self.assertEqual(responses[i]["s"], "randomS%d" % i) + + def test_mod_config(self): + from letsencrypt.client.apache.obj import Addr + for chall in self.challs: + self.sni.add_chall(chall) + v_addr1 = [Addr(("1.2.3.4", "443")), Addr(("5.6.7.8", "443"))] + v_addr2 = [Addr(("127.0.0.1", "443"))] + ll_addr = [] + ll_addr.append(v_addr1) + ll_addr.append(v_addr2) + self.sni._mod_config(ll_addr) # pylint: disable=protected-access + self.sni.config.save() + + self.sni.config.parser.find_dir("Include", self.sni.challenge_conf) + vh_match = self.sni.config.aug.match( + "/files" + self.sni.challenge_conf + "//VirtualHost") + + vhs = [] + for match in vh_match: + # pylint: disable=protected-access + vhs.append(self.sni.config._create_vhost(match)) + self.assertEqual(len(vhs), 2) + for vhost in vhs: + if vhost.addrs == set(v_addr1): + self.assertEqual( + vhost.names, + set([str(self.challs[0].nonce + CONFIG.INVALID_EXT)])) + else: + self.assertEqual(vhost.addrs, set(v_addr2)) + self.assertEqual( + vhost.names, + set([str(self.challs[1].nonce + CONFIG.INVALID_EXT)])) diff --git a/letsencrypt/client/tests/apache_obj_test.py b/letsencrypt/client/tests/apache/obj_test.py similarity index 67% rename from letsencrypt/client/tests/apache_obj_test.py rename to letsencrypt/client/tests/apache/obj_test.py index 46e76d4bd..151880ac4 100644 --- a/letsencrypt/client/tests/apache_obj_test.py +++ b/letsencrypt/client/tests/apache/obj_test.py @@ -1,14 +1,13 @@ +"""Test the helper objects in apache.obj.py.""" import unittest -from letsencrypt.client.apache import obj - - class AddrTest(unittest.TestCase): """Test the Addr class.""" def setUp(self): - self.addr1 = obj.Addr.fromstring("192.168.1.1") - self.addr2 = obj.Addr.fromstring("192.168.1.1:*") - self.addr3 = obj.Addr.fromstring("192.168.1.1:80") + from letsencrypt.client.apache.obj import Addr + self.addr1 = Addr.fromstring("192.168.1.1") + self.addr2 = Addr.fromstring("192.168.1.1:*") + self.addr3 = Addr.fromstring("192.168.1.1:80") def test_fromstring(self): self.assertEqual(self.addr1.get_addr(), "192.168.1.1") @@ -36,9 +35,10 @@ class AddrTest(unittest.TestCase): self.assertNotEqual(self.addr1, 3333) def test_set_inclusion(self): + from letsencrypt.client.apache.obj import Addr set_a = set([self.addr1, self.addr2]) - addr1b = obj.Addr.fromstring("192.168.1.1") - addr2b = obj.Addr.fromstring("192.168.1.1:*") + addr1b = Addr.fromstring("192.168.1.1") + addr2b = Addr.fromstring("192.168.1.1:*") set_b = set([addr1b, addr2b]) self.assertEqual(set_a, set_b) @@ -47,14 +47,18 @@ class AddrTest(unittest.TestCase): class VirtualHostTest(unittest.TestCase): """Test the VirtualHost class.""" def setUp(self): - self.vhost1 = obj.VirtualHost( + from letsencrypt.client.apache.obj import VirtualHost + from letsencrypt.client.apache.obj import Addr + self.vhost1 = VirtualHost( "filep", "vh_path", - set([obj.Addr.fromstring("localhost")]), False, False) + set([Addr.fromstring("localhost")]), False, False) def test_eq(self): - vhost1b = obj.VirtualHost( + from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.apache.obj import VirtualHost + vhost1b = VirtualHost( "filep", "vh_path", - set([obj.Addr.fromstring("localhost")]), False, False) + set([Addr.fromstring("localhost")]), False, False) self.assertEqual(vhost1b, self.vhost1) self.assertEqual(str(vhost1b), str(self.vhost1)) diff --git a/letsencrypt/client/tests/apache_parser_test.py b/letsencrypt/client/tests/apache/parser_test.py similarity index 96% rename from letsencrypt/client/tests/apache_parser_test.py rename to letsencrypt/client/tests/apache/parser_test.py index 340cdd324..2acf6533e 100644 --- a/letsencrypt/client/tests/apache_parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -1,3 +1,4 @@ +"""Tests the ApacheParser class.""" import os import shutil import sys @@ -10,11 +11,11 @@ import zope.component from letsencrypt.client import display from letsencrypt.client import errors from letsencrypt.client.apache import parser -from letsencrypt.client.tests import config_util +from letsencrypt.client.tests.apache import config_util class ApacheParserTest(unittest.TestCase): - + """Apache Parser Test.""" def setUp(self): zope.component.provideUtility(display.FileDisplay(sys.stdout)) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py new file mode 100644 index 000000000..ee0a79895 --- /dev/null +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -0,0 +1,425 @@ +"""Test auth_handler.py.""" +import unittest +import mock + +from letsencrypt.client.tests import acme_util + + +TRANSLATE = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + +# pylint: disable=protected-access +class SatisfyChallengesTest(unittest.TestCase): + """verify_identities test.""" + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator') + + self.mock_dv_auth.get_chall_pref.return_value = ["dvsni"] + self.mock_client_auth.get_chall_pref.return_value = ["recoveryToken"] + + self.mock_client_auth.perform.side_effect = gen_auth_resp + self.mock_dv_auth.perform.side_effect = gen_auth_resp + + self.handler = AuthHandler( + self.mock_dv_auth, self.mock_client_auth, None) + + def test_name1_dvsni1(self): + dom = "0" + challenge = [acme_util.CHALLENGES["dvsni"]] + msg = acme_util.get_chall_msg(dom, "nonce0", challenge) + self.handler.add_chall_msg(dom, msg, "dummy_key") + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), 1) + + self.assertEqual("DvsniChall0", self.handler.responses[dom][0]) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + + def test_name5_dvsni5(self): + challenge = [acme_util.CHALLENGES["dvsni"]] + for i in range(5): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge), + "dummy_key") + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + # Each message contains 1 auth, 0 client + + for i in range(5): + dom = str(i) + self.assertEqual(len(self.handler.responses[dom]), 1) + self.assertEqual(self.handler.responses[dom][0], "DvsniChall%d" % i) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name1_auth(self, mock_chall_path): + dom = "0" + + challenges = acme_util.get_dv_challenges() + combos = acme_util.gen_combos(challenges) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg("0", "nonce0", challenges, combos), + "dummy_key") + + path = gen_path(["simpleHttps"], challenges) + mock_chall_path.return_value = path + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name1_all(self, mock_chall_path): + dom = "0" + + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg(dom, "nonce0", challenges, combos), + "dummy_key") + + path = gen_path(["simpleHttps", "recoveryToken"], challenges) + mock_chall_path.return_value = path + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 1) + + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.client_c[dom][0].chall).__name__, "RecTokenChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name5_all(self, mock_chall_path): + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + for i in range(5): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenges, combos), + "dummy_key") + + path = gen_path(["dvsni", "recoveryContact"], challenges) + mock_chall_path.return_value = path + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + for i in range(5): + self.assertEqual( + len(self.handler.responses[str(i)]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + + for i in range(5): + dom = str(i) + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 1) + + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall") + self.assertEqual( + type(self.handler.client_c[dom][0].chall).__name__, + "RecContactChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name5_mix(self, mock_chall_path): + paths = [] + chosen_chall = [["dns"], + ["dvsni"], + ["simpleHttps", "proofOfPossession"], + ["simpleHttps"], + ["dns", "recoveryToken"]] + challenge_list = [acme_util.get_dv_challenges(), + [acme_util.CHALLENGES["dvsni"]], + acme_util.get_challenges(), + acme_util.get_dv_challenges(), + acme_util.get_challenges()] + + # Combos doesn't matter since I am overriding the gen_path function + for i in range(5): + dom = str(i) + paths.append(gen_path(chosen_chall[i], challenge_list[i])) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg( + dom, "nonce%d" % i, challenge_list[i]), + "dummy_key") + + mock_chall_path.side_effect = paths + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + + for i in range(5): + dom = str(i) + resp = self._get_exp_response(i, paths[i], challenge_list[i]) + self.assertEqual(self.handler.responses[dom], resp) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual( + len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) + + self.assertEqual( + type(self.handler.dv_c["0"][0].chall).__name__, "DnsChall") + self.assertEqual( + type(self.handler.dv_c["1"][0].chall).__name__, "DvsniChall") + self.assertEqual( + type(self.handler.dv_c["2"][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.dv_c["3"][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.dv_c["4"][0].chall).__name__, "DnsChall") + + self.assertEqual( + type(self.handler.client_c["2"][0].chall).__name__, "PopChall") + self.assertEqual( + type(self.handler.client_c["4"][0].chall).__name__, "RecTokenChall") + + def _get_exp_response(self, domain, path, challenges): + exp_resp = ["null"] * len(challenges) + for i in path: + exp_resp[i] = TRANSLATE[challenges[i]["type"]] + str(domain) + + return exp_resp + + +# pylint: disable=protected-access +class GetAuthorizationsTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator') + + self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") + self.mock_acme_auth = mock.MagicMock(name="acme_authorization") + + self.iteration = 0 + + self.handler = AuthHandler( + self.mock_dv_auth, self.mock_client_auth, None) + + self.handler._satisfy_challenges = self.mock_sat_chall + self.handler.acme_authorization = self.mock_acme_auth + + def test_solved3_at_once(self): + # Set 3 DVSNI challenges + challenge = [acme_util.CHALLENGES["dvsni"]] + for i in range(3): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge), + "dummy_key") + + self.mock_sat_chall.side_effect = self._sat_solved_at_once + self.handler.get_authorizations() + + self.assertEqual(self.mock_sat_chall.call_count, 1) + self.assertEqual(self.mock_acme_auth.call_count, 3) + + exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")] + self.assertEqual( + self.mock_acme_auth.call_args_list, exp_call_list) + self._test_finished() + + def _sat_solved_at_once(self): + for i in range(3): + dom = str(i) + self.handler.responses[dom] = ["DvsniChall%d" % i] + self.handler.paths[dom] = [0] + # Assignment was > 80 char... + dv_c, c_c = self.handler._challenge_factory(dom, [0]) + + self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + + def test_progress_failure(self): + from letsencrypt.client.errors import LetsEncryptAuthHandlerError + challenges = acme_util.get_challenges() + self.handler.add_chall_msg( + "0", + acme_util.get_chall_msg("0", "nonce0", challenges), + "dummy_key") + + # Don't do anything to satisfy challenges + self.mock_sat_chall.side_effect = self._sat_failure + + self.assertRaises( + 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( + ["dns", "recoveryToken"], 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.client_c[dom] = dv_c, c_c + + def test_incremental_progress(self): + challs = [] + challs.append(acme_util.get_challenges()) + challs.append(acme_util.get_dv_challenges()) + for i in range(2): + dom = str(i) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg(dom, "nonce%d" % i, challs[i]), + "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): + from letsencrypt.client.errors import LetsEncryptAuthHandlerError + + # 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"] = ["null", "null", "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.client_c["0"] = dv_c, c_c + dv_c, c_c = self.handler._challenge_factory("1", [2]) + self.handler.dv_c["1"], self.handler.client_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 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) + +# pylint: disable=protected-access +class PathSatisfiedTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + self.handler = AuthHandler(None, None, None) + + def test_satisfied_true(self): + dom = ["0", "1", "2", "3", "4"] + self.handler.paths[dom[0]] = [1, 2] + self.handler.responses[dom[0]] = ["null", "sat", "sat2", "null"] + + self.handler.paths[dom[1]] = [0] + self.handler.responses[dom[1]] = ["sat", None, None, "null"] + + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = ["sat"] + + self.handler.paths[dom[3]] = [] + self.handler.responses[dom[3]] = [] + + self.handler.paths[dom[4]] = [] + self.handler.responses[dom[4]] = ["respond... sure"] + + for i in range(5): + self.assertTrue(self.handler._path_satisfied(dom[i])) + + def test_not_satisfied(self): + dom = ["0", "1", "2", "3", "4"] + self.handler.paths[dom[0]] = [1, 2] + self.handler.responses[dom[0]] = ["sat1", "null", "sat2", "null"] + + self.handler.paths[dom[1]] = [0] + self.handler.responses[dom[1]] = [None, "null", "null", "null"] + + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = [None] + + self.handler.paths[dom[3]] = [0] + self.handler.responses[dom[3]] = ["null"] + + for i in range(4): + self.assertFalse(self.handler._path_satisfied(dom[i])) + + +def gen_auth_resp(chall_list): + return ["%s%s" % (type(chall).__name__, chall.domain) + for chall in chall_list] + +def gen_path(str_list, challenges): + path = [] + for i, chall in enumerate(challenges): + for str_chall in str_list: + if chall["type"] == str_chall: + path.append(i) + continue + return path + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/client_authenticator_test.py new file mode 100644 index 000000000..6027e1dba --- /dev/null +++ b/letsencrypt/client/tests/client_authenticator_test.py @@ -0,0 +1,80 @@ +import unittest + +import mock + + +class PerformTest(unittest.TestCase): + """Test client perform function.""" + def setUp(self): + from letsencrypt.client.client_authenticator import ClientAuthenticator + + self.auth = ClientAuthenticator("demo_server.org") + self.auth.rec_token.perform = mock.MagicMock( + name="rec_token_perform", side_effect=gen_client_resp) + + def test_rec_token1(self): + from letsencrypt.client.challenge_util import RecTokenChall + token = RecTokenChall("0") + + responses = self.auth.perform([token]) + + self.assertEqual(responses, ["RecTokenChall0"]) + + def test_rec_token5(self): + from letsencrypt.client.challenge_util import RecTokenChall + tokens = [] + for i in range(5): + tokens.append(RecTokenChall(str(i))) + + responses = self.auth.perform(tokens) + + self.assertEqual(len(responses), 5) + for i in range(5): + self.assertEqual(responses[i], "RecTokenChall%d" % i) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.errors import LetsEncryptClientAuthError + + unexpected = DvsniChall("0", "rb64", "123", "invalid_key") + + self.assertRaises( + LetsEncryptClientAuthError, self.auth.perform, [unexpected]) + + +class CleanupTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.client_authenticator import ClientAuthenticator + + self.auth = ClientAuthenticator("demo_server.org") + self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") + self.auth.rec_token.cleanup = self.mock_cleanup + + def test_rec_token2(self): + from letsencrypt.client.challenge_util import RecTokenChall + token1 = RecTokenChall("0") + token2 = RecTokenChall("1") + + self.auth.cleanup([token1, token2]) + + self.assertEqual(self.mock_cleanup.call_args_list, + [mock.call(token1), mock.call(token2)]) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.challenge_util import RecTokenChall + from letsencrypt.client.errors import LetsEncryptClientAuthError + + token = RecTokenChall("0") + unexpected = DvsniChall("0", "rb64", "123", "dummy_key") + + self.assertRaises( + LetsEncryptClientAuthError, self.auth.cleanup, [token, unexpected]) + + +def gen_client_resp(chall): + return "%s%s" % (type(chall).__name__, chall.domain) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/le_util_test.py b/letsencrypt/client/tests/le_util_test.py index 2432a6d65..f6c58ac0b 100644 --- a/letsencrypt/client/tests/le_util_test.py +++ b/letsencrypt/client/tests/le_util_test.py @@ -83,6 +83,9 @@ class UniqueFileTest(unittest.TestCase): self.root_path = tempfile.mkdtemp() self.default_name = os.path.join(self.root_path, 'foo.txt') + def tearDown(self): + shutil.rmtree(self.root_path, ignore_errors=True) + def _call(self, mode=0o600): from letsencrypt.client.le_util import unique_file return unique_file(self.default_name, mode) diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py new file mode 100644 index 000000000..945c2b0b9 --- /dev/null +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -0,0 +1,64 @@ +"""Tests for recovery_token.py.""" +import os +import unittest +import shutil +import tempfile + +import mock + + +class RecoveryTokenTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.recovery_token import RecoveryToken + server = "demo_server" + self.base_dir = tempfile.mkdtemp("tokens") + self.token_dir = os.path.join(self.base_dir, server) + self.rec_token = RecoveryToken(server, self.base_dir) + + def tearDown(self): + shutil.rmtree(self.base_dir) + + def test_store_token(self): + self.rec_token.store_token("example.com", 111) + path = os.path.join(self.token_dir, "example.com") + self.assertTrue(os.path.isfile(path)) + with open(path) as token_fd: + self.assertEqual(token_fd.read(), "111") + + def test_requires_human(self): + self.rec_token.store_token("example2.com", 222) + self.assertFalse(self.rec_token.requires_human("example2.com")) + self.assertTrue(self.rec_token.requires_human("example3.com")) + + def test_cleanup(self): + from letsencrypt.client.challenge_util import RecTokenChall + self.rec_token.store_token("example3.com", 333) + self.assertFalse(self.rec_token.requires_human("example3.com")) + + self.rec_token.cleanup(RecTokenChall("example3.com")) + self.assertTrue(self.rec_token.requires_human("example3.com")) + + # Shouldn't throw an error + self.rec_token.cleanup(RecTokenChall("example4.com")) + + def test_perform_stored(self): + from letsencrypt.client.challenge_util import RecTokenChall + self.rec_token.store_token("example4.com", 444) + response = self.rec_token.perform(RecTokenChall("example4.com")) + + self.assertEqual(response, {"type": "recoveryToken", "token": "444"}) + + @mock.patch("letsencrypt.client.recovery_token.zope.component.getUtility") + def test_perform_not_stored(self, mock_input): + from letsencrypt.client.challenge_util import RecTokenChall + + mock_input().generic_input.side_effect = [(0, "555"), (1, "000")] + response = self.rec_token.perform(RecTokenChall("example5.com")) + self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) + + response = self.rec_token.perform(RecTokenChall("example6.com")) + self.assertIs(response, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8aeb43136..12db6e33d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -122,7 +122,7 @@ def main(): acme = client.Client(server, domains, privkey, auth, installer) # Validate the key and csr - client.validate_key_csr(privkey, csr, domains) + client.validate_key_csr(privkey, csr) cert_file, chain_file = acme.obtain_certificate(csr) vhost = acme.deploy_certificate(privkey, cert_file, chain_file) @@ -150,8 +150,7 @@ def choose_names(installer): code, names = zope.component.getUtility( interfaces.IDisplay).filter_names(get_all_names(installer)) if code == display.OK and names: - # TODO: Allow multiple names once it is setup - return [names[0]] + return names else: sys.exit(0) diff --git a/tox.ini b/tox.ini index 4ebe69305..f636b5567 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = [testenv:cover] commands = python setup.py dev - python setup.py nosetests --with-coverage --cover-min-percentage=47 + python setup.py nosetests --with-coverage --cover-min-percentage=59 [testenv:lint] commands =