From eb99571a981e97e80f6c62b47ebd3e3a2a1d9a50 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 22 Dec 2014 00:29:33 -0800 Subject: [PATCH 01/14] Use DVSNI_Chall namedtuple --- letsencrypt/client/apache/configurator.py | 44 +++++++++++------------ letsencrypt/client/client.py | 21 +++++++---- letsencrypt/scripts/main.py | 2 +- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 2e2a02238..028157349 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -949,12 +949,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): `chall_dict` composed of: - list_sni_tuple: - List of tuples with form `(name, r, nonce)`, where - `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) + `type`: `dvsni` (`str`) - dvsni_key: - DVSNI key (:class:`letsencrypt.client.client.Client.Key`) + `dvsni_chall`: + List of DVSNI_Chall namedtuples + (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) + where DVSNI_Chall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) :param dict chall_dict: dvsni challenge - see documentation @@ -964,18 +966,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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): + if "dvsni_chall" not 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]) + for chall in chall_dict["dvsni_chall"]: + vhost = self.choose_virtual_host(chall.domain) if vhost is None: logging.error( - "No vhost exists with servername or alias of: %s", tup[0]) + "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 @@ -993,18 +996,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): responses = [] # Create all of the challenge certs - for tup in chall_dict["list_sni_tuple"]: - cert_path = self.dvsni_get_cert_file(tup[2]) + for chall in chall_dict["dvsni_chall"]: + cert_path = self.dvsni_get_cert_file(chall.nonce) 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"]) + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.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) + self.dvsni_mod_config(chall_dict["dvsni_chall"], addresses) # Save reversible changes and restart the server self.save("SNI Challenge", True) @@ -1019,18 +1020,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() # TODO: Variable names - def dvsni_mod_config(self, list_sni_tuple, dvsni_key, - ll_addrs): + def dvsni_mod_config(self, dvsni_chall, 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 dvsni_chall: list of + :class:`letsencrypt.client.client.Client.DVSNI_Chall` :param list ll_addrs: list of list of :class:`letsencrypt.client.apache.obj.Addr` to apply @@ -1052,7 +1048,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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) + dvsni_chall[idx].nonce, lis, dvsni_chall[idx].key.file) config_text += "\n" self.dvsni_conf_include_check(self.parser.loc["default"]) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8c5f4525f..fdb8f542c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -47,6 +47,7 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") + DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -419,8 +420,9 @@ class Client(object): 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"]))) + sni_todo.append(Client.DVSNI_Chall( + str(name), str(chall["r"]), + str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": logging.info("\tRecovery Token Challenge for name: %s.", name) @@ -438,8 +440,7 @@ class Client(object): # one "challenge object" is issued for all sni_challenges challenge_objs.append({ "type": "dvsni", - "list_sni_tuple": sni_todo, - "dvsni_key": self.authkey, + "dvsni_chall": sni_todo }) challenge_obj_indices.append(sni_satisfies) logging.debug(sni_todo) @@ -447,11 +448,17 @@ class Client(object): 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/scripts/main.py b/letsencrypt/scripts/main.py index 8aeb43136..211525ed8 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) From 05d803ddd3aeff364252616142de9b1326c501ba Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 23 Dec 2014 03:54:30 -0800 Subject: [PATCH 02/14] Turn DVSNI into module, add more appropriate challenges/api --- letsencrypt/client/apache/configurator.py | 205 +++------------------- letsencrypt/client/apache/dvsni.py | 193 ++++++++++++++++++++ letsencrypt/client/challenge_util.py | 4 + letsencrypt/client/client.py | 91 ++++------ letsencrypt/client/interfaces.py | 17 +- 5 files changed, 274 insertions(+), 236 deletions(-) create mode 100644 letsencrypt/client/apache/dvsni.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 028157349..dcff472f6 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 @@ -117,6 +115,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 +125,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. @@ -929,186 +924,41 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### - - # TODO: Change list_sni_tuple to namedtuple. Also include key within tuple. - # This allows the keys to be different for each SNI challenge - - 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 dict chall_list: List of challenges to be + fulfilled by configurator. """ + 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.DVSNI_Chall): + apache_dvsni.add_chall(chall, i) - def dvsni_perform(self, chall_dict): - """Peform a DVSNI challenge. - - `chall_dict` composed of: - - `type`: `dvsni` (`str`) - - `dvsni_chall`: - List of DVSNI_Chall namedtuples - (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) - where DVSNI_Chall tuples have the following fields - `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `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 "dvsni_chall" not 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 chall in chall_dict["dvsni_chall"]: - vhost = self.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.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 chall_dict["dvsni_chall"]: - cert_path = self.dvsni_get_cert_file(chall.nonce) - self.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.dvsni_mod_config(chall_dict["dvsni_chall"], 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() + 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.revert_challenge_config() - self.restart() - - # TODO: Variable names - def dvsni_mod_config(self, dvsni_chall, ll_addrs): - """Modifies Apache config files to include challenge vhosts. - - Result: Apache config includes virtual servers for issued challs - - :param list dvsni_chall: list of - :class:`letsencrypt.client.client.Client.DVSNI_Chall` - - :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( - dvsni_chall[idx].nonce, lis, dvsni_chall[idx].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" + self.chall_out -= len(chall_list) + if self.chall_out <= 0: + self.revert_challenge_config() + self.restart() def enable_mod(mod_name): @@ -1217,3 +1067,6 @@ def get_file_path(vhost_path): continue break return avail_fp + + +from letsencrypt.client.apache import dvsni diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py new file mode 100644 index 000000000..b37fd0b1c --- /dev/null +++ b/letsencrypt/client/apache/dvsni.py @@ -0,0 +1,193 @@ +"""ApacheDVSNI""" +import logging +import os +import pkg_resources +import shutil + +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 DVSNI_Chall 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.DVSNI_Chall` + + """ + def __init__(self, config): + self.config = config + self.dvsni_chall = [] + self.indices = [] + # 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.DVSNI_Chall` + + :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 dict() + # 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 chall_complete(self, chall): + # """Used by Authenticator to notify the DVSNI challenge. + + # :param chall: Challenge info + # :type chall: :class:`letsencrypt.client.client.Client.DVSNI_Chall` + + # """ + # self.completed += 1 + # if self.completed < len(self.dvsni_chall): + # return False + # return True + + # TODO: Variable names + 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, CONFIG.APACHE_CHALLENGE_CONF) + + with open(CONFIG.APACHE_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"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + # print "Including challenge virtual host(s)" + self.config.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.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/challenge_util.py b/letsencrypt/client/challenge_util.py index 46b0602be..86b1cab04 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 @@ -8,6 +9,9 @@ from letsencrypt.client import crypto_util from letsencrypt.client import le_util +DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") + + # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): """Generate a DVSNI cert and save it to filepath. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index fdb8f542c..4a698bd48 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,6 +13,7 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import challenge +from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -47,7 +48,6 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") - DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -80,10 +80,10 @@ class Client(object): challenge_msg = self.acme_challenge() # Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_msg) + responses, auth_c, client_c = self.verify_identity(challenge_msg) # Get Authorization - self.acme_authorization(challenge_msg, challenge_objs, responses) + self.acme_authorization(challenge_msg, auth_c, client_c, responses) # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -108,7 +108,7 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(self.names[0]), "challenge") - def acme_authorization(self, challenge_msg, chal_objs, responses): + def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. :param dict challenge_msg: ACME "challenge" message. @@ -132,7 +132,7 @@ class Client(object): "Failed Authorization procedure - cleaning up challenges") sys.exit(1) finally: - self.cleanup_challenges(chal_objs) + self.cleanup_challenges(auth_c, client_c) def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -243,19 +243,16 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, challenges): + def cleanup_challenges(self, auth_c, client_c): """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 + self.auth.cleanup(auth_c) + # should cleanup client_c + assert not client_c def verify_identity(self, challenge_msg): """Verify identity. @@ -275,45 +272,37 @@ class Client(object): # 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( + auth_c, auth_i, client_c, client_i = 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) + # Do client centric challenges here... + # Since this isn't implemented yet... + assert not client_i + auth_resp = self.auth.perform(auth_c) + self._assign_responses(auth_resp, auth_i, responses) logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, challenge_objs + return responses, auth_c, client_c # 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 + :type resp: list of dicts :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 + assert len(resp) == len(index_list) + for j, index in enumerate(index_list): + responses[index] = resp[j] + def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -392,10 +381,10 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, name, challenges, path): + def challenge_factory(self, domain, challenges, path): """ - :param name: TODO + :param str domain: domain of the enrollee :param list challenges: A list of challenges from ACME "challenge" server message to be fulfilled by the client in order to prove @@ -407,27 +396,27 @@ class Client(object): :rtype: tuple """ - sni_todo = [] + auth_chall = [] # Since a single invocation of SNI challenge can satisfy multiple # challenges. We must keep track of all the challenges it satisfies - sni_satisfies = [] + auth_satisfies = [] - challenge_objs = [] - challenge_obj_indices = [] + client_chall = [] + client_satisfies = [] 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(Client.DVSNI_Chall( - str(name), str(chall["r"]), + logging.info(" DVSNI challenge for name %s.", domain) + auth_satisfies.append(index) + auth_chall.append(challenge_util.DVSNI_Chall( + str(domain), str(chall["r"]), str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": - logging.info("\tRecovery Token Challenge for name: %s.", name) - challenge_obj_indices.append(index) - challenge_objs.append({ + logging.info(" Recovery Token Challenge for name: %s.", domain) + client_satisfies.append(index) + client_chall.append({ type: "recoveryToken", }) @@ -435,17 +424,7 @@ class Client(object): 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", - "dvsni_chall": sni_todo - }) - challenge_obj_indices.append(sni_satisfies) - logging.debug(sni_todo) - - return challenge_objs, challenge_obj_indices + return auth_chall, auth_satisfies, client_chall, client_satisfies def validate_key_csr(privkey, csr): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..7b72d9a46 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,17 +11,26 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def perform(chall_dict): - """Perform the given challenge""" + def perform(chall_list): + """Perform the given challenge. - def cleanup(): + :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): + def perform(): """Perform the challenge. :param bool quiet: TODO From f089449bf2fb00c0ad646afc12061f3671bce10b Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 6 Jan 2015 01:57:07 -0800 Subject: [PATCH 03/14] Initial challenge refactor/allow multiple names --- letsencrypt/client/CONFIG.py | 5 +- letsencrypt/client/apache/configurator.py | 14 +- letsencrypt/client/apache/dvsni.py | 20 +-- letsencrypt/client/augeas_configurator.py | 4 +- letsencrypt/client/challenge.py | 14 +- letsencrypt/client/client.py | 144 ++++++++++++++---- letsencrypt/client/interfaces.py | 13 +- letsencrypt/client/network.py | 3 + .../client/tests/apache_configurator_test.py | 37 ++++- letsencrypt/client/tests/le_util_test.py | 3 + letsencrypt/scripts/main.py | 3 +- 11 files changed, 196 insertions(+), 64 deletions(-) diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 3cc9d09a6..6392911fb 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -47,9 +47,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.""" @@ -60,7 +57,7 @@ INVALID_EXT = ".acme.invalid" """Invalid Extension""" # Challenge Information -CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] +#CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] """Challenge Preferences Dict for currently supported challenges""" EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index dcff472f6..12e97f7cc 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -15,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 @@ -924,6 +926,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### + def get_chall_pref(self): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + + return ["dvsni"] + def perform(self, chall_list): """Perform the configuration related challenge. @@ -934,6 +941,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param dict chall_list: List of challenges to be fulfilled by configurator. + :returns: list of responses. A None response indicates the challenge + was not perfromed. + :rtype: list + """ self.chall_out += len(chall_list) responses = [None] * len(chall_list) @@ -1067,6 +1078,3 @@ def get_file_path(vhost_path): continue break return avail_fp - - -from letsencrypt.client.apache import dvsni diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index b37fd0b1c..37b0b7426 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -1,8 +1,6 @@ """ApacheDVSNI""" import logging import os -import pkg_resources -import shutil from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG @@ -27,6 +25,8 @@ class ApacheDVSNI(object): 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): @@ -120,10 +120,10 @@ class ApacheDVSNI(object): # 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) + # 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" @@ -134,9 +134,9 @@ class ApacheDVSNI(object): config_text += "\n" self.conf_include_check(self.config.parser.loc["default"]) - self.config.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) + self.config.register_file_creation(True, self.challenge_conf) - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: + with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) def conf_include_check(self, main_config): @@ -149,10 +149,10 @@ class ApacheDVSNI(object): """ if len(self.config.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + 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", CONFIG.APACHE_CHALLENGE_CONF) + "Include", self.challenge_conf) def get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 231faa99d..5d02329de 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -343,7 +343,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: @@ -400,7 +400,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/challenge.py b/letsencrypt/client/challenge.py index b2eb33c53..b0452c0ff 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -5,7 +5,7 @@ import sys from letsencrypt.client import CONFIG -def gen_challenge_path(challenges, combos=None): +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... @@ -25,12 +25,12 @@ def gen_challenge_path(challenges, combos=None): """ if combos: - return _find_smart_path(challenges, combos) + return _find_smart_path(challenges, preferences, combos) else: - return _find_dumb_path(challenges) + return _find_dumb_path(challenges, preferences) -def _find_smart_path(challenges, combos): +def _find_smart_path(challenges, preferences, combos): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple @@ -51,7 +51,7 @@ def _find_smart_path(challenges, combos): """ chall_cost = {} max_cost = 0 - for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES): + for i, chall in enumerate(preferences): chall_cost[chall] = i max_cost += i @@ -77,7 +77,7 @@ def _find_smart_path(challenges, combos): return best_combo -def _find_dumb_path(challenges): +def _find_dumb_path(challenges, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the @@ -95,7 +95,7 @@ def _find_dumb_path(challenges): # Add logic for a crappy server # Choose a DV path = [] - for pref_c in CONFIG.CHALLENGE_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)): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 4a698bd48..a9305f221 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -60,6 +60,15 @@ class Client(object): self.auth = auth self.installer = installer + # Client challenges and Authenticator challenges should be separate + # and really should not be conflicting along the same path. + # I have chosen to make client challenges preferred + # as the client challenges should be able to be completely handled + # by this module and does not require outside config changes. + # (which may be costly) + self.preferences = ["recoveryToken"] + self.preferences.extend(auth.get_chall_pref()) + def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, chain_path=CONFIG.CHAIN_PATH): @@ -76,14 +85,45 @@ class Client(object): :rtype: `tuple` of `str` """ + challenge_msgs = [] # Request Challenges - challenge_msg = self.acme_challenge() + for name in self.names: + # Maintaining order of challenge_msgs to names is important + challenge_msgs.append(self.acme_challenge(name)) # Perform Challenges - responses, auth_c, client_c = self.verify_identity(challenge_msg) + # Make sure at least one challenge is solved every round + progress = True + # This outer loop handles cases where the Authenticator cannot solve + # all challenge_msgs at once + while challenge_msgs and progress: + responses, auth_c, client_c = self.verify_identities(challenge_msgs) + progress = False - # Get Authorization - self.acme_authorization(challenge_msg, auth_c, client_c, responses) + i = 0 + while i < len(responses): + # Get Authorization + if responses[i] is not None: + print "client chall_msgs:", challenge_msgs[i] + print "client responses:", responses[i] + print "client auth_c:", auth_c[i] + print "client client_c:", client_c[i] + self.acme_authorization( + challenge_msgs[i], auth_c[i], client_c[i], responses[i]) + # Received authorization, remove challenge from list + # We have also cleaned up challenges... keep index + # in sync + del challenge_msgs[i] + del auth_c[i] + del client_c[i] + del responses[i] + progress = True + else: + i += 1 + + if not progress: + raise errors.LetsEncryptClientError( + "Unable to solve challenges for requested names.") # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -96,17 +136,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") + acme.challenge_request(domain), "challenge") def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. @@ -254,55 +292,101 @@ class Client(object): # should cleanup client_c assert not client_c - def verify_identity(self, challenge_msg): - """Verify identity. + def verify_identities(self, challenge_msgs): + """Verify identities. - :param dict challenge_msg: ACME "challenge" message. + This is greatly complicated by the fact that the Authenticator can + oftentimes solve many challenges at once. The strategy is to give + the authenticator all of the appropriate challenges at once to + speed up the process. This creates indexing issues as the challenges + can come from many different messages and are not in an exact order + because of the optimal path decision. All of this complicated indexing + will be completely hidden from the authenticator and all the + authenticator must do is return a list of responses in the same order + the challenges were given. + + :param list challenge_msgs: List of ACME "challenge" messages. :returns: TODO - :rtype: dict + :rtype: TODO """ - path = challenge.gen_challenge_path( - challenge_msg["challenges"], challenge_msg.get("combinations", [])) + # Every msg's responses are a list within this list + responses = [] + # Every msg's desired path + paths = [] - logging.info("Performing the following challenges:") + auth_chall = [] + client_chall = [] - # 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 - auth_c, auth_i, client_c, client_i = self.challenge_factory( - self.names[0], challenge_msg["challenges"], path) + auth_idx = [] + client_idx = [] - responses = ["null"] * len(challenge_msg["challenges"]) + for i, msg in enumerate(challenge_msgs): + paths.append(challenge.gen_challenge_path( + msg["challenges"], + self.preferences, + msg.get("combinations", []))) + + logging.info("Performing the following challenges:") + + auth_c, auth_i, client_c, client_i = self.challenge_factory( + self.names[i], msg["challenges"], paths[-1]) + + auth_chall.append(auth_c) + auth_idx.append(auth_i) + client_chall.append(client_c) + client_idx.append(client_i) + + responses.append(["null"] * len(msg["challenges"])) # Do client centric challenges here... # Since this isn't implemented yet... + # Client challenge responses should be cached... + # The client should be able to solve all challenges the first time assert not client_i - auth_resp = self.auth.perform(auth_c) - self._assign_responses(auth_resp, auth_i, responses) + # Flatten list for authenticator + auth_resp = self.auth.perform( + [chall for sublist in auth_chall for chall in sublist]) + self._assign_responses(auth_resp, auth_idx, responses) + + print 'auth_resp:', auth_resp + print 'auth_idx:', auth_idx + print 'auth_responses:', responses + + for i in range(len(paths)): + # If challenges failed to complete... zero them out + if not self._path_satisfied(responses[i], paths[i]): + responses[i] = None + auth_chall[i] = None + client_chall[i] = None logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, auth_c, client_c + return responses, auth_chall, client_chall # pylint: disable=no-self-use - def _assign_responses(self, resp, index_list, responses): + def _assign_responses(self, flat_resp, idx_list, responses): """Assign chall_response to appropriate places in response list. :param resp: responses from a challenge :type resp: list of dicts - :param list index_list: respective challenges resp satisfies + :param list idx_list: respective challenges flat_resp satisfies :param list responses: master list of responses """ - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] + flat_index = 0 + # Every authorization_request message + for msg_num in range(len(responses)): + for idx in idx_list[msg_num]: + responses[msg_num][idx] = flat_resp[flat_index] + flat_index += 1 + def _path_satisfied(self, responses, path): + """Returns whether a path has been completely satisfied.""" + return all("null" != responses[i] for i in path) def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 7b72d9a46..5586960de 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,6 +11,13 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ + def get_chall_pref(): + """Return list of challenge preferences. + + :returns: list of strings with the most preferred challenges first. + :rtype: list + + """ def perform(chall_list): """Perform the given challenge. @@ -31,11 +38,7 @@ class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" def perform(): - """Perform the challenge. - - :param bool quiet: TODO - - """ + """Perform the challenge.""" def generate_response(): """Generate response.""" diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index 855008b6b..92f2933ec 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/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index e1fd718a2..a1bb50004 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,6 +8,8 @@ import unittest import mock import zope.component +from letsencrypt.client import challenge_util +from letsencrypt.client import client from letsencrypt.client import display from letsencrypt.client import errors @@ -159,5 +162,37 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) + @mock.patch("letsencrypt.client.apache.dvsni") + def test_perform(self, mock_dvsni, mock_restart): + # 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( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + chall1 = challenge_util.DVSNI_Chall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + chall2 = challenge_util.DVSNI_Chall( + "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) + 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/scripts/main.py b/letsencrypt/scripts/main.py index 211525ed8..12db6e33d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -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) From 7c23a2f2aad9eb7a3bcee1555d56d511d070c4fa Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 22 Dec 2014 00:29:33 -0800 Subject: [PATCH 04/14] Use DVSNI_Chall namedtuple --- letsencrypt/client/apache/configurator.py | 44 +++++++++++------------ letsencrypt/client/client.py | 21 +++++++---- letsencrypt/scripts/main.py | 2 +- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 8b32cf7a7..752651104 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -949,12 +949,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): `chall_dict` composed of: - list_sni_tuple: - List of tuples with form `(name, r, nonce)`, where - `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) + `type`: `dvsni` (`str`) - dvsni_key: - DVSNI key (:class:`letsencrypt.client.client.Client.Key`) + `dvsni_chall`: + List of DVSNI_Chall namedtuples + (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) + where DVSNI_Chall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) :param dict chall_dict: dvsni challenge - see documentation @@ -964,18 +966,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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): + if "dvsni_chall" not 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]) + for chall in chall_dict["dvsni_chall"]: + vhost = self.choose_virtual_host(chall.domain) if vhost is None: logging.error( - "No vhost exists with servername or alias of: %s", tup[0]) + "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 @@ -993,18 +996,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): responses = [] # Create all of the challenge certs - for tup in chall_dict["list_sni_tuple"]: - cert_path = self.dvsni_get_cert_file(tup[2]) + for chall in chall_dict["dvsni_chall"]: + cert_path = self.dvsni_get_cert_file(chall.nonce) 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"]) + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.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) + self.dvsni_mod_config(chall_dict["dvsni_chall"], addresses) # Save reversible changes and restart the server self.save("SNI Challenge", True) @@ -1019,18 +1020,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() # TODO: Variable names - def dvsni_mod_config(self, list_sni_tuple, dvsni_key, - ll_addrs): + def dvsni_mod_config(self, dvsni_chall, 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 dvsni_chall: list of + :class:`letsencrypt.client.client.Client.DVSNI_Chall` :param list ll_addrs: list of list of :class:`letsencrypt.client.apache.obj.Addr` to apply @@ -1052,7 +1048,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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) + dvsni_chall[idx].nonce, lis, dvsni_chall[idx].key.file) config_text += "\n" self.dvsni_conf_include_check(self.parser.loc["default"]) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8c5f4525f..fdb8f542c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -47,6 +47,7 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") + DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -419,8 +420,9 @@ class Client(object): 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"]))) + sni_todo.append(Client.DVSNI_Chall( + str(name), str(chall["r"]), + str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": logging.info("\tRecovery Token Challenge for name: %s.", name) @@ -438,8 +440,7 @@ class Client(object): # one "challenge object" is issued for all sni_challenges challenge_objs.append({ "type": "dvsni", - "list_sni_tuple": sni_todo, - "dvsni_key": self.authkey, + "dvsni_chall": sni_todo }) challenge_obj_indices.append(sni_satisfies) logging.debug(sni_todo) @@ -447,11 +448,17 @@ class Client(object): 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/scripts/main.py b/letsencrypt/scripts/main.py index 8aeb43136..211525ed8 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) From a2a64e94100fdb7b1139a540ad98a637e613b2de Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 23 Dec 2014 03:54:30 -0800 Subject: [PATCH 05/14] Turn DVSNI into module, add more appropriate challenges/api --- letsencrypt/client/apache/configurator.py | 203 +++------------------- letsencrypt/client/apache/dvsni.py | 193 ++++++++++++++++++++ letsencrypt/client/challenge_util.py | 4 + letsencrypt/client/client.py | 91 ++++------ letsencrypt/client/interfaces.py | 17 +- 5 files changed, 272 insertions(+), 236 deletions(-) create mode 100644 letsencrypt/client/apache/dvsni.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 752651104..92c45bdf2 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,6 +15,7 @@ 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 @@ -117,6 +116,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 +126,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. @@ -929,186 +925,41 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### - - # TODO: Change list_sni_tuple to namedtuple. Also include key within tuple. - # This allows the keys to be different for each SNI challenge - - 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. """ + 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.DVSNI_Chall): + apache_dvsni.add_chall(chall, i) - def dvsni_perform(self, chall_dict): - """Perform a DVSNI challenge. - - `chall_dict` composed of: - - `type`: `dvsni` (`str`) - - `dvsni_chall`: - List of DVSNI_Chall namedtuples - (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) - where DVSNI_Chall tuples have the following fields - `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `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 "dvsni_chall" not 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 chall in chall_dict["dvsni_chall"]: - vhost = self.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.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 chall_dict["dvsni_chall"]: - cert_path = self.dvsni_get_cert_file(chall.nonce) - self.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.dvsni_mod_config(chall_dict["dvsni_chall"], 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() + 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.revert_challenge_config() - self.restart() - - # TODO: Variable names - def dvsni_mod_config(self, dvsni_chall, ll_addrs): - """Modifies Apache config files to include challenge vhosts. - - Result: Apache config includes virtual servers for issued challs - - :param list dvsni_chall: list of - :class:`letsencrypt.client.client.Client.DVSNI_Chall` - - :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( - dvsni_chall[idx].nonce, lis, dvsni_chall[idx].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" + self.chall_out -= len(chall_list) + 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..b37fd0b1c --- /dev/null +++ b/letsencrypt/client/apache/dvsni.py @@ -0,0 +1,193 @@ +"""ApacheDVSNI""" +import logging +import os +import pkg_resources +import shutil + +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 DVSNI_Chall 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.DVSNI_Chall` + + """ + def __init__(self, config): + self.config = config + self.dvsni_chall = [] + self.indices = [] + # 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.DVSNI_Chall` + + :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 dict() + # 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 chall_complete(self, chall): + # """Used by Authenticator to notify the DVSNI challenge. + + # :param chall: Challenge info + # :type chall: :class:`letsencrypt.client.client.Client.DVSNI_Chall` + + # """ + # self.completed += 1 + # if self.completed < len(self.dvsni_chall): + # return False + # return True + + # TODO: Variable names + 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, CONFIG.APACHE_CHALLENGE_CONF) + + with open(CONFIG.APACHE_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"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + # print "Including challenge virtual host(s)" + self.config.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.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/challenge_util.py b/letsencrypt/client/challenge_util.py index 26266cda1..6eee4d3f9 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 @@ -8,6 +9,9 @@ from letsencrypt.client import crypto_util from letsencrypt.client import le_util +DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") + + # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): """Generate a DVSNI cert and save it to filepath. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index fdb8f542c..4a698bd48 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,6 +13,7 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import challenge +from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -47,7 +48,6 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") - DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -80,10 +80,10 @@ class Client(object): challenge_msg = self.acme_challenge() # Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_msg) + responses, auth_c, client_c = self.verify_identity(challenge_msg) # Get Authorization - self.acme_authorization(challenge_msg, challenge_objs, responses) + self.acme_authorization(challenge_msg, auth_c, client_c, responses) # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -108,7 +108,7 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(self.names[0]), "challenge") - def acme_authorization(self, challenge_msg, chal_objs, responses): + def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. :param dict challenge_msg: ACME "challenge" message. @@ -132,7 +132,7 @@ class Client(object): "Failed Authorization procedure - cleaning up challenges") sys.exit(1) finally: - self.cleanup_challenges(chal_objs) + self.cleanup_challenges(auth_c, client_c) def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -243,19 +243,16 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, challenges): + def cleanup_challenges(self, auth_c, client_c): """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 + self.auth.cleanup(auth_c) + # should cleanup client_c + assert not client_c def verify_identity(self, challenge_msg): """Verify identity. @@ -275,45 +272,37 @@ class Client(object): # 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( + auth_c, auth_i, client_c, client_i = 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) + # Do client centric challenges here... + # Since this isn't implemented yet... + assert not client_i + auth_resp = self.auth.perform(auth_c) + self._assign_responses(auth_resp, auth_i, responses) logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, challenge_objs + return responses, auth_c, client_c # 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 + :type resp: list of dicts :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 + assert len(resp) == len(index_list) + for j, index in enumerate(index_list): + responses[index] = resp[j] + def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -392,10 +381,10 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, name, challenges, path): + def challenge_factory(self, domain, challenges, path): """ - :param name: TODO + :param str domain: domain of the enrollee :param list challenges: A list of challenges from ACME "challenge" server message to be fulfilled by the client in order to prove @@ -407,27 +396,27 @@ class Client(object): :rtype: tuple """ - sni_todo = [] + auth_chall = [] # Since a single invocation of SNI challenge can satisfy multiple # challenges. We must keep track of all the challenges it satisfies - sni_satisfies = [] + auth_satisfies = [] - challenge_objs = [] - challenge_obj_indices = [] + client_chall = [] + client_satisfies = [] 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(Client.DVSNI_Chall( - str(name), str(chall["r"]), + logging.info(" DVSNI challenge for name %s.", domain) + auth_satisfies.append(index) + auth_chall.append(challenge_util.DVSNI_Chall( + str(domain), str(chall["r"]), str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": - logging.info("\tRecovery Token Challenge for name: %s.", name) - challenge_obj_indices.append(index) - challenge_objs.append({ + logging.info(" Recovery Token Challenge for name: %s.", domain) + client_satisfies.append(index) + client_chall.append({ type: "recoveryToken", }) @@ -435,17 +424,7 @@ class Client(object): 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", - "dvsni_chall": sni_todo - }) - challenge_obj_indices.append(sni_satisfies) - logging.debug(sni_todo) - - return challenge_objs, challenge_obj_indices + return auth_chall, auth_satisfies, client_chall, client_satisfies def validate_key_csr(privkey, csr): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..7b72d9a46 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,17 +11,26 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def perform(chall_dict): - """Perform the given challenge""" + def perform(chall_list): + """Perform the given challenge. - def cleanup(): + :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): + def perform(): """Perform the challenge. :param bool quiet: TODO From 73ec1311c0479085977b6669f23316fcb01383ff Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 6 Jan 2015 01:57:07 -0800 Subject: [PATCH 06/14] Initial challenge refactor/allow multiple names --- letsencrypt/client/CONFIG.py | 5 +- letsencrypt/client/apache/configurator.py | 10 ++ letsencrypt/client/apache/dvsni.py | 20 +-- letsencrypt/client/augeas_configurator.py | 4 +- letsencrypt/client/challenge.py | 14 +- letsencrypt/client/client.py | 144 ++++++++++++++---- letsencrypt/client/interfaces.py | 13 +- letsencrypt/client/network.py | 3 + .../client/tests/apache_configurator_test.py | 37 ++++- letsencrypt/client/tests/le_util_test.py | 3 + letsencrypt/scripts/main.py | 3 +- 11 files changed, 195 insertions(+), 61 deletions(-) diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 3cc9d09a6..6392911fb 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -47,9 +47,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.""" @@ -60,7 +57,7 @@ INVALID_EXT = ".acme.invalid" """Invalid Extension""" # Challenge Information -CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] +#CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] """Challenge Preferences Dict for currently supported challenges""" EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 92c45bdf2..74ef227fc 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -19,6 +19,7 @@ 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 @@ -925,6 +926,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### + def get_chall_pref(self): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + + return ["dvsni"] + def perform(self, chall_list): """Perform the configuration related challenge. @@ -935,6 +941,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param list chall_list: List of challenges to be fulfilled by configurator. + :returns: list of responses. A None response indicates the challenge + was not perfromed. + :rtype: list + """ self.chall_out += len(chall_list) responses = [None] * len(chall_list) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index b37fd0b1c..37b0b7426 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -1,8 +1,6 @@ """ApacheDVSNI""" import logging import os -import pkg_resources -import shutil from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG @@ -27,6 +25,8 @@ class ApacheDVSNI(object): 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): @@ -120,10 +120,10 @@ class ApacheDVSNI(object): # 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) + # 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" @@ -134,9 +134,9 @@ class ApacheDVSNI(object): config_text += "\n" self.conf_include_check(self.config.parser.loc["default"]) - self.config.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) + self.config.register_file_creation(True, self.challenge_conf) - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: + with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) def conf_include_check(self, main_config): @@ -149,10 +149,10 @@ class ApacheDVSNI(object): """ if len(self.config.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + 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", CONFIG.APACHE_CHALLENGE_CONF) + "Include", self.challenge_conf) def get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 231faa99d..5d02329de 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -343,7 +343,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: @@ -400,7 +400,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/challenge.py b/letsencrypt/client/challenge.py index b2eb33c53..b0452c0ff 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -5,7 +5,7 @@ import sys from letsencrypt.client import CONFIG -def gen_challenge_path(challenges, combos=None): +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... @@ -25,12 +25,12 @@ def gen_challenge_path(challenges, combos=None): """ if combos: - return _find_smart_path(challenges, combos) + return _find_smart_path(challenges, preferences, combos) else: - return _find_dumb_path(challenges) + return _find_dumb_path(challenges, preferences) -def _find_smart_path(challenges, combos): +def _find_smart_path(challenges, preferences, combos): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple @@ -51,7 +51,7 @@ def _find_smart_path(challenges, combos): """ chall_cost = {} max_cost = 0 - for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES): + for i, chall in enumerate(preferences): chall_cost[chall] = i max_cost += i @@ -77,7 +77,7 @@ def _find_smart_path(challenges, combos): return best_combo -def _find_dumb_path(challenges): +def _find_dumb_path(challenges, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the @@ -95,7 +95,7 @@ def _find_dumb_path(challenges): # Add logic for a crappy server # Choose a DV path = [] - for pref_c in CONFIG.CHALLENGE_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)): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 4a698bd48..a9305f221 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -60,6 +60,15 @@ class Client(object): self.auth = auth self.installer = installer + # Client challenges and Authenticator challenges should be separate + # and really should not be conflicting along the same path. + # I have chosen to make client challenges preferred + # as the client challenges should be able to be completely handled + # by this module and does not require outside config changes. + # (which may be costly) + self.preferences = ["recoveryToken"] + self.preferences.extend(auth.get_chall_pref()) + def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, chain_path=CONFIG.CHAIN_PATH): @@ -76,14 +85,45 @@ class Client(object): :rtype: `tuple` of `str` """ + challenge_msgs = [] # Request Challenges - challenge_msg = self.acme_challenge() + for name in self.names: + # Maintaining order of challenge_msgs to names is important + challenge_msgs.append(self.acme_challenge(name)) # Perform Challenges - responses, auth_c, client_c = self.verify_identity(challenge_msg) + # Make sure at least one challenge is solved every round + progress = True + # This outer loop handles cases where the Authenticator cannot solve + # all challenge_msgs at once + while challenge_msgs and progress: + responses, auth_c, client_c = self.verify_identities(challenge_msgs) + progress = False - # Get Authorization - self.acme_authorization(challenge_msg, auth_c, client_c, responses) + i = 0 + while i < len(responses): + # Get Authorization + if responses[i] is not None: + print "client chall_msgs:", challenge_msgs[i] + print "client responses:", responses[i] + print "client auth_c:", auth_c[i] + print "client client_c:", client_c[i] + self.acme_authorization( + challenge_msgs[i], auth_c[i], client_c[i], responses[i]) + # Received authorization, remove challenge from list + # We have also cleaned up challenges... keep index + # in sync + del challenge_msgs[i] + del auth_c[i] + del client_c[i] + del responses[i] + progress = True + else: + i += 1 + + if not progress: + raise errors.LetsEncryptClientError( + "Unable to solve challenges for requested names.") # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -96,17 +136,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") + acme.challenge_request(domain), "challenge") def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. @@ -254,55 +292,101 @@ class Client(object): # should cleanup client_c assert not client_c - def verify_identity(self, challenge_msg): - """Verify identity. + def verify_identities(self, challenge_msgs): + """Verify identities. - :param dict challenge_msg: ACME "challenge" message. + This is greatly complicated by the fact that the Authenticator can + oftentimes solve many challenges at once. The strategy is to give + the authenticator all of the appropriate challenges at once to + speed up the process. This creates indexing issues as the challenges + can come from many different messages and are not in an exact order + because of the optimal path decision. All of this complicated indexing + will be completely hidden from the authenticator and all the + authenticator must do is return a list of responses in the same order + the challenges were given. + + :param list challenge_msgs: List of ACME "challenge" messages. :returns: TODO - :rtype: dict + :rtype: TODO """ - path = challenge.gen_challenge_path( - challenge_msg["challenges"], challenge_msg.get("combinations", [])) + # Every msg's responses are a list within this list + responses = [] + # Every msg's desired path + paths = [] - logging.info("Performing the following challenges:") + auth_chall = [] + client_chall = [] - # 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 - auth_c, auth_i, client_c, client_i = self.challenge_factory( - self.names[0], challenge_msg["challenges"], path) + auth_idx = [] + client_idx = [] - responses = ["null"] * len(challenge_msg["challenges"]) + for i, msg in enumerate(challenge_msgs): + paths.append(challenge.gen_challenge_path( + msg["challenges"], + self.preferences, + msg.get("combinations", []))) + + logging.info("Performing the following challenges:") + + auth_c, auth_i, client_c, client_i = self.challenge_factory( + self.names[i], msg["challenges"], paths[-1]) + + auth_chall.append(auth_c) + auth_idx.append(auth_i) + client_chall.append(client_c) + client_idx.append(client_i) + + responses.append(["null"] * len(msg["challenges"])) # Do client centric challenges here... # Since this isn't implemented yet... + # Client challenge responses should be cached... + # The client should be able to solve all challenges the first time assert not client_i - auth_resp = self.auth.perform(auth_c) - self._assign_responses(auth_resp, auth_i, responses) + # Flatten list for authenticator + auth_resp = self.auth.perform( + [chall for sublist in auth_chall for chall in sublist]) + self._assign_responses(auth_resp, auth_idx, responses) + + print 'auth_resp:', auth_resp + print 'auth_idx:', auth_idx + print 'auth_responses:', responses + + for i in range(len(paths)): + # If challenges failed to complete... zero them out + if not self._path_satisfied(responses[i], paths[i]): + responses[i] = None + auth_chall[i] = None + client_chall[i] = None logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, auth_c, client_c + return responses, auth_chall, client_chall # pylint: disable=no-self-use - def _assign_responses(self, resp, index_list, responses): + def _assign_responses(self, flat_resp, idx_list, responses): """Assign chall_response to appropriate places in response list. :param resp: responses from a challenge :type resp: list of dicts - :param list index_list: respective challenges resp satisfies + :param list idx_list: respective challenges flat_resp satisfies :param list responses: master list of responses """ - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] + flat_index = 0 + # Every authorization_request message + for msg_num in range(len(responses)): + for idx in idx_list[msg_num]: + responses[msg_num][idx] = flat_resp[flat_index] + flat_index += 1 + def _path_satisfied(self, responses, path): + """Returns whether a path has been completely satisfied.""" + return all("null" != responses[i] for i in path) def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 7b72d9a46..5586960de 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,6 +11,13 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ + def get_chall_pref(): + """Return list of challenge preferences. + + :returns: list of strings with the most preferred challenges first. + :rtype: list + + """ def perform(chall_list): """Perform the given challenge. @@ -31,11 +38,7 @@ class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" def perform(): - """Perform the challenge. - - :param bool quiet: TODO - - """ + """Perform the challenge.""" def generate_response(): """Generate response.""" 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/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index e1fd718a2..a1bb50004 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,6 +8,8 @@ import unittest import mock import zope.component +from letsencrypt.client import challenge_util +from letsencrypt.client import client from letsencrypt.client import display from letsencrypt.client import errors @@ -159,5 +162,37 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) + @mock.patch("letsencrypt.client.apache.dvsni") + def test_perform(self, mock_dvsni, mock_restart): + # 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( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + chall1 = challenge_util.DVSNI_Chall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + chall2 = challenge_util.DVSNI_Chall( + "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) + 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/scripts/main.py b/letsencrypt/scripts/main.py index 211525ed8..12db6e33d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -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) From 21b8e10560dba69bdf86367133cb295e10990c59 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 6 Jan 2015 02:15:24 -0800 Subject: [PATCH 07/14] Add dvsni documentation --- docs/api/client/apache.rst | 6 ++++++ 1 file changed, 6 insertions(+) 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` ==================================== From 0bef6769ba8f88f6dba886937710adf0439a6f30 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 9 Jan 2015 05:30:15 -0800 Subject: [PATCH 08/14] cleanup challenge infrastructure --- letsencrypt/client/CONFIG.py | 17 +- letsencrypt/client/apache/configurator.py | 9 +- letsencrypt/client/apache/dvsni.py | 22 +- letsencrypt/client/apache/parser.py | 1 + letsencrypt/client/challenge.py | 1 + letsencrypt/client/challenge_util.py | 11 +- letsencrypt/client/client.py | 171 ++++++++--- letsencrypt/client/interfaces.py | 8 +- letsencrypt/client/recovery_token.py | 82 +++++ .../client/recovery_token_challenge.py | 37 --- letsencrypt/client/tests/acme_util.py | 112 +++++++ .../client/tests/apache_configurator_test.py | 17 +- letsencrypt/client/tests/apache_dvsni_test.py | 120 ++++++++ letsencrypt/client/tests/client_test.py | 282 ++++++++++++++++++ .../client/tests/recovery_token_test.py | 64 ++++ tox.ini | 2 +- 16 files changed, 837 insertions(+), 119 deletions(-) create mode 100644 letsencrypt/client/recovery_token.py delete mode 100644 letsencrypt/client/recovery_token_challenge.py create mode 100644 letsencrypt/client/tests/acme_util.py create mode 100644 letsencrypt/client/tests/apache_dvsni_test.py create mode 100644 letsencrypt/client/tests/client_test.py create mode 100644 letsencrypt/client/tests/recovery_token_test.py diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 6392911fb..9a850778c 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""" @@ -56,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""" +AUTH_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 74ef227fc..229718e63 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -811,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 @@ -926,7 +926,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### - def get_chall_pref(self): # pylint: disable=no-self-use + # pylint: disable=no-self-use, unused-argument + def get_chall_pref(self, domain): """Return list of challenge preferences.""" return ["dvsni"] @@ -948,10 +949,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ self.chall_out += len(chall_list) responses = [None] * len(chall_list) - apache_dvsni = dvsni.ApacheDVSNI(self) + apache_dvsni = dvsni.ApacheDvsni(self) for i, chall in enumerate(chall_list): - if isinstance(chall, challenge_util.DVSNI_Chall): + if isinstance(chall, challenge_util.DvsniChall): apache_dvsni.add_chall(chall, i) sni_response = apache_dvsni.perform() diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 37b0b7426..4f5549ffc 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -7,18 +7,19 @@ from letsencrypt.client import CONFIG from letsencrypt.client.apache import parser -class ApacheDVSNI(object): + +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 DVSNI_Chall tuples have the following fields + 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.DVSNI_Chall` + :class:`letsencrypt.client.challenge_util.DvsniChall` """ def __init__(self, config): @@ -33,7 +34,7 @@ class ApacheDVSNI(object): """Add challenge to DVSNI object to perform at once. :param chall: DVSNI challenge info - :type chall: :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + :type chall: :class:`letsencrypt.client.challenge_util.DvsniChall` :param int idx: index to challenge in a larger array @@ -91,19 +92,6 @@ class ApacheDVSNI(object): return responses - # def chall_complete(self, chall): - # """Used by Authenticator to notify the DVSNI challenge. - - # :param chall: Challenge info - # :type chall: :class:`letsencrypt.client.client.Client.DVSNI_Chall` - - # """ - # self.completed += 1 - # if self.completed < len(self.dvsni_chall): - # return False - # return True - - # TODO: Variable names def mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. 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/challenge.py b/letsencrypt/client/challenge.py index b0452c0ff..5abb78684 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -105,6 +105,7 @@ def _find_dumb_path(challenges, preferences): 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 diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 6eee4d3f9..fb7b8d267 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -8,8 +8,17 @@ 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") -DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, 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") # DVSNI Challenge functions diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a9305f221..92da4540d 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -20,6 +20,7 @@ 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 recovery_token # it's weird to point to chocolate servers via raw IPv6 addresses, and @@ -40,12 +41,15 @@ class Client(object): :type authkey: :class:`letsencrypt.client.client.Client.Key` :ivar auth: Object that supports the IAuthenticator interface. + `auth` is used specifically for CONFIG.AUTH_CHALLENGES :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :ivar installer: Object supporting the IInstaller interface. :type installer: :class:`letsencrypt.client.interfaces.IInstraller` """ + zope.interface.implements(interfaces.IAuthenticator) + Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") @@ -60,14 +64,7 @@ class Client(object): self.auth = auth self.installer = installer - # Client challenges and Authenticator challenges should be separate - # and really should not be conflicting along the same path. - # I have chosen to make client challenges preferred - # as the client challenges should be able to be completely handled - # by this module and does not require outside config changes. - # (which may be costly) - self.preferences = ["recoveryToken"] - self.preferences.extend(auth.get_chall_pref()) + self.rec_token = recovery_token.RecoveryToken(server) def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, @@ -104,12 +101,9 @@ class Client(object): while i < len(responses): # Get Authorization if responses[i] is not None: - print "client chall_msgs:", challenge_msgs[i] - print "client responses:", responses[i] - print "client auth_c:", auth_c[i] - print "client client_c:", client_c[i] self.acme_authorization( - challenge_msgs[i], auth_c[i], client_c[i], responses[i]) + challenge_msgs[i], self.names[i], + auth_c[i], client_c[i], responses[i]) # Received authorization, remove challenge from list # We have also cleaned up challenges... keep index # in sync @@ -146,13 +140,15 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(domain), "challenge") - def acme_authorization(self, challenge_msg, auth_c, client_c, responses): + def acme_authorization( + self, challenge_msg, domain, auth_c, client_c, 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 + :param str domain: domain that is requesting authorization + :param list auth_c: auth challenges + :param list client_c: client challenges + :param list responses: Responses to all challenges in challenge_msg :returns: ACME "authorization" message. :rtype: dict @@ -161,7 +157,7 @@ class Client(object): try: return self.network.send_and_receive_expected( acme.authorization_request( - challenge_msg["sessionID"], self.names[0], + challenge_msg["sessionID"], domain, challenge_msg["nonce"], responses, self.authkey.pem), "authorization") except errors.LetsEncryptClientError as err: @@ -322,10 +318,20 @@ class Client(object): auth_idx = [] client_idx = [] + # Client challenges and Authenticator challenges should be separate + # and really should not be conflicting along the same path. + # I have chosen to make client challenges preferred + # as the client challenges should be able to be completely handled + # by this module and does not require outside config changes. + # (which may be costly) + for i, msg in enumerate(challenge_msgs): + prefs = self.get_chall_pref(self.names[i]) + prefs.extend(self.auth.get_chall_pref(self.names[i])) + paths.append(challenge.gen_challenge_path( msg["challenges"], - self.preferences, + prefs, msg.get("combinations", []))) logging.info("Performing the following challenges:") @@ -340,20 +346,16 @@ class Client(object): responses.append(["null"] * len(msg["challenges"])) - # Do client centric challenges here... - # Since this isn't implemented yet... - # Client challenge responses should be cached... - # The client should be able to solve all challenges the first time - assert not client_i - # Flatten list for authenticator + # Flatten list for client authenticator functions + client_resp = self.perform( + [chall for sublist in client_chall for chall in sublist]) + self._assign_responses(client_resp, client_idx, responses) + + # Flatten list for auth authenticator auth_resp = self.auth.perform( [chall for sublist in auth_chall for chall in sublist]) self._assign_responses(auth_resp, auth_idx, responses) - print 'auth_resp:', auth_resp - print 'auth_idx:', auth_idx - print 'auth_responses:', responses - for i in range(len(paths)): # If challenges failed to complete... zero them out if not self._path_satisfied(responses[i], paths[i]): @@ -476,9 +478,17 @@ class Client(object): :param list path: List of indices from `challenges`. - :returns: A pair of TODO + :returns: auth_chall, list of `collections.namedtuples` + auth_satisfies, list of indices, each associated auth_chall + satisfieswithin the challenge_msg + client_chall, list of `collections.namedtuples` + client_satisfies, list of indices each associated client_chall + satisfies within the challenge_msg :rtype: tuple + :raises errors.LetsEncryptClientError: If Challenge type is not + recognized + """ auth_chall = [] # Since a single invocation of SNI challenge can satisfy multiple @@ -487,29 +497,106 @@ class Client(object): client_chall = [] client_satisfies = [] + domain = str(domain) + for index in path: chall = challenges[index] - if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", domain) + # Authenticator Challenges + if chall["type"] in CONFIG.AUTH_CHALLENGES: + auth_chall.append(self._construct_auth_chall(chall, domain)) auth_satisfies.append(index) - auth_chall.append(challenge_util.DVSNI_Chall( - str(domain), str(chall["r"]), - str(chall["nonce"]), self.authkey)) - elif chall["type"] == "recoveryToken": - logging.info(" Recovery Token Challenge for name: %s.", domain) + # Client Challenges + elif chall["type"] in CONFIG.CLIENT_CHALLENGES: + client_chall.append(self._construct_client_chall(chall, domain)) client_satisfies.append(index) - client_chall.append({ - type: "recoveryToken", - }) else: - logging.fatal("Challenge not currently supported") - sys.exit(82) + raise errors.LetsEncryptClientError( + "Received unrecognized challenge of type: " + "%s" % chall["type"]) return auth_chall, auth_satisfies, client_chall, client_satisfies + def _construct_auth_chall(self, chall, domain): + """Construct Auth Type Challenges. + + :param dict chall: Single challenge + + :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) + + elif chall["type"] == "simpleHttps": + logging.info(" SimpleHTTPS challenge for name %s.", domain) + return challenge_util.SimpleHttpsChall( + domain, str(chall["token"]), self.authkey) + + elif chall["type"] == "dns": + logging.info(" DNS challenge for name %s.", domain) + return challenge_util.DnsChall( + domain, str(chall["token"]), self.authkey) + + 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 + + :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"]) + + # pylint: disable=unused-argument + def get_chall_pref(self, domain): + """Return list of challenge preferences.""" + return ["recoveryToken"] + + def perform(self, chall_list): + """Perform client specific challenges.""" + responses = [] + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + responses.append(self.rec_token.perform(chall)) + else: + raise errors.LetsEncryptClientError("Unexpected Challenge") + return responses + def validate_key_csr(privkey, csr): """Validate CSR and key files. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 5586960de..be3c6e09f 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,9 +11,11 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def get_chall_pref(): + def get_chall_pref(domain): """Return list of challenge preferences. - + + :param str domain: Domain for which challenge preferences are sought. + :returns: list of strings with the most preferred challenges first. :rtype: list @@ -22,7 +24,7 @@ class IAuthenticator(zope.interface.Interface): """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 diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py new file mode 100644 index 000000000..b6111aea1 --- /dev/null +++ b/letsencrypt/client/recovery_token.py @@ -0,0 +1,82 @@ +"""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. + + """ + # zope.interface.implements(interfaces.IChallenge) + + def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): + # super(RecoveryToken, self).__init__() + 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..086733bd8 --- /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_auth_challenges(): + """Returns all auth challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.AUTH_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.AUTH_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_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index a1bb50004..5426409b5 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -162,8 +162,11 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) - @mock.patch("letsencrypt.client.apache.dvsni") - def test_perform(self, mock_dvsni, mock_restart): + @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( @@ -172,12 +175,12 @@ class TwoVhost80Test(unittest.TestCase): __name__, 'testdata/rsa256_key.pem') auth_key = client.Client.Key(rsa256_file, rsa256_pem) - chall1 = challenge_util.DVSNI_Chall( + chall1 = challenge_util.DvsniChall( "encryption-example.demo", "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", "37bc5eb75d3e00a19b4f6355845e5a18", auth_key) - chall2 = challenge_util.DVSNI_Chall( + chall2 = challenge_util.DvsniChall( "letsencrypt.demo", "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", "59ed014cac95f77057b1d7a1b2c596ba", @@ -188,11 +191,13 @@ class TwoVhost80Test(unittest.TestCase): {"type": "dvsni", "s": "randomS2"} ] - mock_dvsni().perform.return_value = dvsni_ret_val + mock_dvsni_perform.return_value = dvsni_ret_val responses = self.config.perform([chall1, chall2]) - self.assertEqual(mock_dvsni.perform.call_count, 1) + 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..b50997541 --- /dev/null +++ b/letsencrypt/client/tests/apache_dvsni_test.py @@ -0,0 +1,120 @@ +"""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 import display + +from letsencrypt.client.apache import obj + +from letsencrypt.client.tests 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( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + self.chall1 = challenge_util.DvsniChall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + self.chall2 = challenge_util.DvsniChall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key) + + self.sni.add_chall(self.chall1) + self.sni.add_chall(self.chall2) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform(self, mock_dvsni_gen_cert, mock_restart): + mock_dvsni_gen_cert.side_effect = ["randomS1", "randomS2"] + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 2) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [ + (self.sni.get_cert_file(self.chall1.nonce), self.chall1.domain, + self.chall1.r_b64, self.chall1.nonce, self.chall1.key), + (self.sni.get_cert_file(self.chall2.nonce), self.chall2.domain, + self.chall2.r_b64, self.chall2.nonce, self.chall2.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) + self.assertEqual(responses[0]["s"], "randomS1") + self.assertEqual(responses[1]["s"], "randomS2") + + def test_mod_config(self): + v_addr1 = [obj.Addr(("1.2.3.4", "443")), obj.Addr(("5.6.7.8", "443"))] + v_addr2 = [obj.Addr(("127.0.0.1", "443"))] + ll_addr = [] + ll_addr.append(v_addr1) + ll_addr.append(v_addr2) + self.sni.mod_config(ll_addr) + 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.chall1.nonce + CONFIG.INVALID_EXT)])) + else: + self.assertEqual(vhost.addrs, set(v_addr2)) + self.assertEqual( + vhost.names, + set([str(self.chall2.nonce + CONFIG.INVALID_EXT)])) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py new file mode 100644 index 000000000..e22a95c64 --- /dev/null +++ b/letsencrypt/client/tests/client_test.py @@ -0,0 +1,282 @@ +"""Test client.py.""" +import unittest +import mock +import pkg_resources + +from letsencrypt.client.tests import acme_util + + +class VerifyIdentityTest(unittest.TestCase): + """verify_identities test.""" + def setUp(self): + from letsencrypt.client.client import Client + from letsencrypt.client import CONFIG + + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = Client.Key(rsa256_file, rsa256_pem) + + self.mock_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_auth.get_chall_pref.return_value = ["dvsni"] + self.mock_auth.perform.side_effect = gen_auth_resp + + self.client = Client( + CONFIG.ACME_SERVER, ["0", "1", "2", "3", "4"], + auth_key, self.mock_auth, None) + self.client.perform = mock.MagicMock( + name='perform', side_effect=gen_auth_resp) + + def test_name1_dvsni1(self): + self.client.names = ["0"] + challenge = [acme_util.CHALLENGES["dvsni"]] + msgs = [acme_util.get_chall_msg("0", "nonce0", challenge)] + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 1) + self.assertEqual(len(responses[0]), 1) + + self.assertEqual("DvsniChall0", responses[0][0]) + self.assertEqual(len(auth_c), 1) + self.assertEqual(len(client_c), 1) + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 0) + + def test_name5_dvsni5(self): + challenge = [acme_util.CHALLENGES["dvsni"]] + msgs = [] + for i in range(5): + msgs.append( + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge)) + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 5) + self.assertEqual(len(auth_c), 5) + self.assertEqual(len(client_c), 5) + # Each message contains 1 auth, 0 client + for i in range(5): + self.assertEqual(len(responses[i]), 1) + self.assertEqual(responses[i][0], "DvsniChall%d" % i) + self.assertEqual(len(auth_c[i]), 1) + self.assertEqual(len(client_c[i]), 0) + self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name1_auth(self, mock_chall_path): + self.client.names = ["0"] + + challenges = acme_util.get_auth_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] + + path = gen_path(["simpleHttps"], challenges) + mock_chall_path.return_value = path + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 1) + self.assertEqual(len(responses[0]), len(challenges)) + self.assertEqual(len(auth_c), 1) + self.assertEqual(len(client_c), 1) + + self.assertEqual( + responses[0], + self._get_exp_response("0", path, challenges)) + + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 0) + self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name1_all(self, mock_chall_path): + self.client.names = ["0"] + + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] + + path = gen_path(["simpleHttps", "recoveryToken"], challenges) + mock_chall_path.return_value = path + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 1) + self.assertEqual(len(responses[0]), len(challenges)) + self.assertEqual(len(auth_c), 1) + self.assertEqual(len(client_c), 1) + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 1) + + self.assertEqual( + responses[0], + self._get_exp_response("0", path, challenges)) + self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") + self.assertEqual(type(client_c[0][0]).__name__, "RecTokenChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name5_all(self, mock_chall_path): + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [] + for i in range(5): + msgs.append( + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenges, combos)) + + path = gen_path(["dvsni", "recoveryContact"], challenges) + mock_chall_path.return_value = path + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 5) + for i in range(5): + self.assertEqual(len(responses[i]), len(challenges)) + self.assertEqual(len(auth_c), 5) + self.assertEqual(len(client_c), 5) + + for i in range(5): + self.assertEqual( + responses[i], self._get_exp_response(i, path, challenges)) + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 1) + + self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") + self.assertEqual(type(client_c[i][0]).__name__, "RecContactChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name5_mix(self, mock_chall_path): + paths = [] + msgs = [] + chosen_chall = [["dns"], + ["dvsni"], + ["simpleHttps", "proofOfPossession"], + ["simpleHttps"], + ["dns", "recoveryToken"]] + challenge_list = [acme_util.get_auth_challenges(), + [acme_util.CHALLENGES["dvsni"]], + acme_util.get_challenges(), + acme_util.get_auth_challenges(), + acme_util.get_challenges()] + + # Combos doesn't matter since I am overriding the gen_path function + for i in range(5): + paths.append(gen_path(chosen_chall[i], challenge_list[i])) + msgs.append( + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenge_list[i])) + + mock_chall_path.side_effect = paths + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 5) + self.assertEqual(len(auth_c), 5) + self.assertEqual(len(client_c), 5) + + for i in range(5): + resp = self._get_exp_response(i, paths[i], challenge_list[i]) + self.assertEqual(responses[i], resp) + self.assertEqual(len(auth_c[i]), 1) + self.assertEqual(len(client_c[i]), len(chosen_chall[i]) - 1) + + self.assertEqual(type(auth_c[0][0]).__name__, "DnsChall") + self.assertEqual(type(auth_c[1][0]).__name__, "DvsniChall") + self.assertEqual(type(auth_c[2][0]).__name__, "SimpleHttpsChall") + self.assertEqual(type(auth_c[3][0]).__name__, "SimpleHttpsChall") + self.assertEqual(type(auth_c[4][0]).__name__, "DnsChall") + + self.assertEqual(type(client_c[2][0]).__name__, "PopChall") + self.assertEqual(type(client_c[4][0]).__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 + + +class ClientPerformTest(unittest.TestCase): + """Test client perform function.""" + def setUp(self): + from letsencrypt.client.client import Client + from letsencrypt.client import CONFIG + + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = Client.Key(rsa256_file, rsa256_pem) + + self.client = Client( + CONFIG.ACME_SERVER, ["example.com"], auth_key, None, None) + self.client.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.client.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.client.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 LetsEncryptClientError + unexpected = DvsniChall("0", "rb64", "123", "invalid_key") + + self.assertRaises( + LetsEncryptClientError, self.client.perform, [unexpected]) + + +translate = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + +def gen_auth_resp(chall_list): + return ["%s%s" % (type(chall).__name__, chall.domain) + for chall in chall_list] + + +def gen_client_resp(chall): + return "%s%s" % (type(chall).__name__, chall.domain) + + +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/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py new file mode 100644 index 000000000..240f60cf7 --- /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.assertEqual(response, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 4ebe69305..dc6b05e31 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=60 [testenv:lint] commands = From ca7628caae3110a945850bce195e3c4f98c613f7 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 9 Jan 2015 22:25:36 -0800 Subject: [PATCH 09/14] Small changes/renaming --- letsencrypt/client/apache/configurator.py | 2 +- letsencrypt/client/apache/dvsni.py | 24 +++-- letsencrypt/client/tests/apache/__init__.py | 1 + .../client/tests/{ => apache}/config_util.py | 2 +- .../configurator_test.py} | 9 +- .../dvsni_test.py} | 88 +++++++++++++------ .../obj_test.py} | 28 +++--- .../parser_test.py} | 2 +- .../client/tests/recovery_token_test.py | 2 +- tox.ini | 2 +- 10 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 letsencrypt/client/tests/apache/__init__.py rename letsencrypt/client/tests/{ => apache}/config_util.py (98%) rename letsencrypt/client/tests/{apache_configurator_test.py => apache/configurator_test.py} (96%) rename letsencrypt/client/tests/{apache_dvsni_test.py => apache/dvsni_test.py} (55%) rename letsencrypt/client/tests/{apache_obj_test.py => apache/obj_test.py} (67%) rename letsencrypt/client/tests/{apache_parser_test.py => apache/parser_test.py} (98%) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 229718e63..488730dbf 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -801,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 diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 4f5549ffc..a9d08da27 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -21,6 +21,16 @@ class ApacheDvsni(object): :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 @@ -46,7 +56,7 @@ class ApacheDvsni(object): def perform(self): """Peform a DVSNI challenge.""" if not self.dvsni_chall: - return dict() + return None # Save any changes to the configuration as a precaution # About to make temporary changes to the config self.config.save() @@ -85,14 +95,14 @@ class ApacheDvsni(object): responses.append({"type": "dvsni", "s": s_b64}) # Setup the configuration - self.mod_config(addresses) + self._mod_config(addresses) # Save reversible changes self.config.save("SNI Challenge", True) return responses - def mod_config(self, ll_addrs): + def _mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. Result: Apache config includes virtual servers for issued challs @@ -116,18 +126,18 @@ class ApacheDvsni(object): # 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( + 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._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): + 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 @@ -142,7 +152,7 @@ class ApacheDvsni(object): 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): + def _get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text :param str nonce: hex form of nonce 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 96% rename from letsencrypt/client/tests/apache_configurator_test.py rename to letsencrypt/client/tests/apache/configurator_test.py index 5426409b5..00560c970 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -10,21 +10,20 @@ import zope.component from letsencrypt.client import challenge_util from letsencrypt.client import client -from letsencrypt.client import display 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") @@ -170,9 +169,9 @@ class TwoVhost80Test(unittest.TestCase): # 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( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') auth_key = client.Client.Key(rsa256_file, rsa256_pem) chall1 = challenge_util.DvsniChall( diff --git a/letsencrypt/client/tests/apache_dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py similarity index 55% rename from letsencrypt/client/tests/apache_dvsni_test.py rename to letsencrypt/client/tests/apache/dvsni_test.py index b50997541..6beb07b37 100644 --- a/letsencrypt/client/tests/apache_dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -10,18 +10,15 @@ import zope.component from letsencrypt.client import challenge_util from letsencrypt.client import client from letsencrypt.client import CONFIG -from letsencrypt.client import display -from letsencrypt.client.apache import obj - -from letsencrypt.client.tests import config_util +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()) + #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") @@ -38,44 +35,46 @@ class DvsniPerformTest(unittest.TestCase): self.sni = dvsni.ApacheDvsni(config) rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') auth_key = client.Client.Key(rsa256_file, rsa256_pem) - self.chall1 = challenge_util.DvsniChall( + self.challs = [] + self.challs.append(challenge_util.DvsniChall( "encryption-example.demo", "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", "37bc5eb75d3e00a19b4f6355845e5a18", - auth_key) - self.chall2 = challenge_util.DvsniChall( + auth_key)) + self.challs.append(challenge_util.DvsniChall( "letsencrypt.demo", "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", "59ed014cac95f77057b1d7a1b2c596ba", - auth_key) - - self.sni.add_chall(self.chall1) - self.sni.add_chall(self.chall2) + 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_perform(self, mock_dvsni_gen_cert, mock_restart): - mock_dvsni_gen_cert.side_effect = ["randomS1", "randomS2"] + 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, 2) + 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(self.chall1.nonce), self.chall1.domain, - self.chall1.r_b64, self.chall1.nonce, self.chall1.key), - (self.sni.get_cert_file(self.chall2.nonce), self.chall2.domain, - self.chall2.r_b64, self.chall2.nonce, self.chall2.key) + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key) ] for i in range(len(expected_call_list)): @@ -86,17 +85,50 @@ class DvsniPerformTest(unittest.TestCase): len(self.sni.config.parser.find_dir( "Include", self.sni.challenge_conf)), 1) - self.assertEqual(len(responses), 2) + self.assertEqual(len(responses), 1) self.assertEqual(responses[0]["s"], "randomS1") - self.assertEqual(responses[1]["s"], "randomS2") + + @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): - v_addr1 = [obj.Addr(("1.2.3.4", "443")), obj.Addr(("5.6.7.8", "443"))] - v_addr2 = [obj.Addr(("127.0.0.1", "443"))] + 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) + 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) @@ -112,9 +144,9 @@ class DvsniPerformTest(unittest.TestCase): if vhost.addrs == set(v_addr1): self.assertEqual( vhost.names, - set([str(self.chall1.nonce + CONFIG.INVALID_EXT)])) + set([str(self.challs[0].nonce + CONFIG.INVALID_EXT)])) else: self.assertEqual(vhost.addrs, set(v_addr2)) self.assertEqual( vhost.names, - set([str(self.chall2.nonce + CONFIG.INVALID_EXT)])) + 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 98% rename from letsencrypt/client/tests/apache_parser_test.py rename to letsencrypt/client/tests/apache/parser_test.py index 340cdd324..b7f1f6aa2 100644 --- a/letsencrypt/client/tests/apache_parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -10,7 +10,7 @@ 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): diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 240f60cf7..945c2b0b9 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -57,7 +57,7 @@ class RecoveryTokenTest(unittest.TestCase): self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) response = self.rec_token.perform(RecTokenChall("example6.com")) - self.assertEqual(response, None) + self.assertIs(response, None) if __name__ == '__main__': diff --git a/tox.ini b/tox.ini index dc6b05e31..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=60 + python setup.py nosetests --with-coverage --cover-min-percentage=59 [testenv:lint] commands = From 8f062ddc54456bd0a84de47ee3a3697bd0028da9 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 9 Jan 2015 22:29:46 -0800 Subject: [PATCH 10/14] update documentation for recovery_token --- docs/api/client/recovery_token.rst | 5 +++++ docs/api/client/recovery_token_challenge.rst | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 docs/api/client/recovery_token.rst delete mode 100644 docs/api/client/recovery_token_challenge.rst 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: From be5ae7ae9a9bb2a8181fcf1255b5bc5938a6468d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sat, 10 Jan 2015 05:19:22 -0800 Subject: [PATCH 11/14] Created auth_handler and client_authenticator. Use dicts for all messages and keep client clean. --- docs/api/client/auth_handler.rst | 5 + docs/api/client/client_authenticator.rst | 5 + letsencrypt/client/CONFIG.py | 2 +- letsencrypt/client/auth_handler.py | 418 +++++++++++++++++++++ letsencrypt/client/challenge.py | 118 ------ letsencrypt/client/challenge_util.py | 3 + letsencrypt/client/client.py | 235 ++---------- letsencrypt/client/client_authenticator.py | 44 +++ letsencrypt/client/errors.py | 10 +- 9 files changed, 506 insertions(+), 334 deletions(-) create mode 100644 docs/api/client/auth_handler.rst create mode 100644 docs/api/client/client_authenticator.rst create mode 100644 letsencrypt/client/auth_handler.py delete mode 100644 letsencrypt/client/challenge.py create mode 100644 letsencrypt/client/client_authenticator.py 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/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 9a850778c..2ce39a73b 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -60,7 +60,7 @@ INVALID_EXT = ".acme.invalid" EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] """Mutually Exclusive Challenges - only solve 1""" -AUTH_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) +DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) """These are challenges that must be solved by an Authenticator object""" CLIENT_CHALLENGES = frozenset( diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py new file mode 100644 index 000000000..7ac0f7429 --- /dev/null +++ b/letsencrypt/client/auth_handler.py @@ -0,0 +1,418 @@ +"""ACME AuthHandler.""" +import logging +import sys + +import zope.component + +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 Challenges 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 _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 _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) + + # 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.""" + 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 _get_chall_pref(self, domain): + """Return list of challenge preferences.""" + chall_prefs = 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. + + :returns: List of indices from `challenges`. + :rtype: list + + """ + # Add logic for a crappy server + # Choose a DV + path = [] + 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 5abb78684..000000000 --- a/letsencrypt/client/challenge.py +++ /dev/null @@ -1,118 +0,0 @@ -"""ACME challenge.""" -import logging -import sys - -from letsencrypt.client import CONFIG - - -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 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. - - :returns: List of indices from `challenges`. - :rtype: list - - """ - # Add logic for a crappy server - # Choose a DV - path = [] - 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_util.py b/letsencrypt/client/challenge_util.py index fb7b8d267..2341270cd 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -20,6 +20,9 @@ RecContactChall = collections.namedtuple( 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 92da4540d..a88440aa7 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -12,8 +12,9 @@ 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 challenge_util +from letsencrypt.client import client_authenticator from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -40,9 +41,9 @@ class Client(object): :ivar authkey: Authorization Key :type authkey: :class:`letsencrypt.client.client.Client.Key` - :ivar auth: Object that supports the IAuthenticator interface. - `auth` is used specifically for CONFIG.AUTH_CHALLENGES - :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.IInstraller` @@ -53,18 +54,26 @@ class Client(object): 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 - self.rec_token = recovery_token.RecoveryToken(server) + 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, @@ -82,42 +91,13 @@ class Client(object): :rtype: `tuple` of `str` """ - challenge_msgs = [] # Request Challenges for name in self.names: - # Maintaining order of challenge_msgs to names is important - challenge_msgs.append(self.acme_challenge(name)) + self.auth_handler.add_chall_msg( + name, self.acme_challenge(name), self.authkey) - # Perform Challenges - # Make sure at least one challenge is solved every round - progress = True - # This outer loop handles cases where the Authenticator cannot solve - # all challenge_msgs at once - while challenge_msgs and progress: - responses, auth_c, client_c = self.verify_identities(challenge_msgs) - progress = False - - i = 0 - while i < len(responses): - # Get Authorization - if responses[i] is not None: - self.acme_authorization( - challenge_msgs[i], self.names[i], - auth_c[i], client_c[i], responses[i]) - # Received authorization, remove challenge from list - # We have also cleaned up challenges... keep index - # in sync - del challenge_msgs[i] - del auth_c[i] - del client_c[i] - del responses[i] - progress = True - else: - i += 1 - - if not progress: - raise errors.LetsEncryptClientError( - "Unable to solve challenges for requested names.") + # Perform Challenges/Get Authorizations + self.auth_handler.get_authorizations() # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -140,34 +120,6 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(domain), "challenge") - def acme_authorization( - self, challenge_msg, domain, auth_c, client_c, responses): - """Handle ACME "authorization" phase. - - :param dict challenge_msg: ACME "challenge" message. - :param str domain: domain that is requesting authorization - :param list auth_c: auth challenges - :param list client_c: client challenges - :param list responses: Responses to all challenges in challenge_msg - - :returns: ACME "authorization" message. - :rtype: dict - - """ - try: - return self.network.send_and_receive_expected( - acme.authorization_request( - challenge_msg["sessionID"], domain, - 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(auth_c, client_c) - def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -277,17 +229,6 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, auth_c, client_c): - """Cleanup configuration challenges - - :param dict challenges: challenges from a challenge message - - """ - logging.info("Cleaning up challenges...") - self.auth.cleanup(auth_c) - # should cleanup client_c - assert not client_c - def verify_identities(self, challenge_msgs): """Verify identities. @@ -386,10 +327,6 @@ class Client(object): responses[msg_num][idx] = flat_resp[flat_index] flat_index += 1 - def _path_satisfied(self, responses, path): - """Returns whether a path has been completely satisfied.""" - return all("null" != responses[i] for i in path) - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -467,136 +404,6 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, domain, challenges, path): - """ - - :param str domain: domain of the enrollee - - :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: auth_chall, list of `collections.namedtuples` - auth_satisfies, list of indices, each associated auth_chall - satisfieswithin the challenge_msg - client_chall, list of `collections.namedtuples` - client_satisfies, list of indices each associated client_chall - satisfies within the challenge_msg - :rtype: tuple - - :raises errors.LetsEncryptClientError: If Challenge type is not - recognized - - """ - auth_chall = [] - # Since a single invocation of SNI challenge can satisfy multiple - # challenges. We must keep track of all the challenges it satisfies - auth_satisfies = [] - - client_chall = [] - client_satisfies = [] - domain = str(domain) - - for index in path: - chall = challenges[index] - - # Authenticator Challenges - if chall["type"] in CONFIG.AUTH_CHALLENGES: - auth_chall.append(self._construct_auth_chall(chall, domain)) - auth_satisfies.append(index) - - # Client Challenges - elif chall["type"] in CONFIG.CLIENT_CHALLENGES: - client_chall.append(self._construct_client_chall(chall, domain)) - client_satisfies.append(index) - - else: - raise errors.LetsEncryptClientError( - "Received unrecognized challenge of type: " - "%s" % chall["type"]) - - return auth_chall, auth_satisfies, client_chall, client_satisfies - - def _construct_auth_chall(self, chall, domain): - """Construct Auth Type Challenges. - - :param dict chall: Single challenge - - :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) - - elif chall["type"] == "simpleHttps": - logging.info(" SimpleHTTPS challenge for name %s.", domain) - return challenge_util.SimpleHttpsChall( - domain, str(chall["token"]), self.authkey) - - elif chall["type"] == "dns": - logging.info(" DNS challenge for name %s.", domain) - return challenge_util.DnsChall( - domain, str(chall["token"]), self.authkey) - - 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 - - :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"]) - - # pylint: disable=unused-argument - def get_chall_pref(self, domain): - """Return list of challenge preferences.""" - return ["recoveryToken"] - - def perform(self, chall_list): - """Perform client specific challenges.""" - responses = [] - for chall in chall_list: - if isinstance(chall, challenge_util.RecTokenChall): - responses.append(self.rec_token.perform(chall)) - else: - raise errors.LetsEncryptClientError("Unexpected Challenge") - return responses - def validate_key_csr(privkey, csr): """Validate CSR and key files. diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py new file mode 100644 index 000000000..fe4c95d3b --- /dev/null +++ b/letsencrypt/client/client_authenticator.py @@ -0,0 +1,44 @@ +import zope.interface + +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import recovery_token + +class ClientAuthenticator(object): + """Authenticator 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) + + def get_chall_pref(self, domain): # pylint: disable=no-member-use + """Return list of challenge preferences.""" + return ["recoveryToken"] + + def perform(self, chall_list): + """Perform client specific challenges.""" + 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): + 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): From ae20f2fd7df5787ce0c50d5fe311e59ad1f808e6 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 12 Jan 2015 05:44:06 -0800 Subject: [PATCH 12/14] tests for new code --- letsencrypt/client/auth_handler.py | 25 +- letsencrypt/client/client.py | 98 ---- letsencrypt/client/client_authenticator.py | 4 +- letsencrypt/client/tests/acme_util.py | 6 +- letsencrypt/client/tests/auth_handler_test.py | 444 ++++++++++++++++++ .../client/tests/client_authenticator_test.py | 80 ++++ letsencrypt/client/tests/client_test.py | 282 ----------- 7 files changed, 547 insertions(+), 392 deletions(-) create mode 100644 letsencrypt/client/tests/auth_handler_test.py create mode 100644 letsencrypt/client/tests/client_authenticator_test.py delete mode 100644 letsencrypt/client/tests/client_test.py diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 7ac0f7429..9ecb868ce 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -61,7 +61,8 @@ class AuthHandler(object): """ if domain in self.domains: raise errors.LetsEncryptAuthHandlerError( - "Multiple Challenges for the same domain is not supported.") + "Multiple ACMEChallengeMessages for the same domain " + "is not supported.") self.domains.append(domain) self.responses[domain] = ["null"] * len(msg["challenges"]) self.msgs[domain] = msg @@ -121,12 +122,6 @@ class AuthHandler(object): finally: self._cleanup_challenges(domain) - 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 _satisfy_challenges(self): """Attempt to satisfy all saved challenge messages.""" logging.info("Performing the following challenges:") @@ -151,6 +146,8 @@ class AuthHandler(object): 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) @@ -163,9 +160,16 @@ class AuthHandler(object): 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.""" - chall_prefs = self.client_auth.get_chall_pref(domain) + 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 @@ -389,6 +393,10 @@ def _find_dumb_path(challenges, preferences): 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 @@ -396,6 +404,7 @@ def _find_dumb_path(challenges, preferences): # 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 diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a88440aa7..bba729d35 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -229,104 +229,6 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def verify_identities(self, challenge_msgs): - """Verify identities. - - This is greatly complicated by the fact that the Authenticator can - oftentimes solve many challenges at once. The strategy is to give - the authenticator all of the appropriate challenges at once to - speed up the process. This creates indexing issues as the challenges - can come from many different messages and are not in an exact order - because of the optimal path decision. All of this complicated indexing - will be completely hidden from the authenticator and all the - authenticator must do is return a list of responses in the same order - the challenges were given. - - :param list challenge_msgs: List of ACME "challenge" messages. - - :returns: TODO - :rtype: TODO - - """ - # Every msg's responses are a list within this list - responses = [] - # Every msg's desired path - paths = [] - - auth_chall = [] - client_chall = [] - - auth_idx = [] - client_idx = [] - - # Client challenges and Authenticator challenges should be separate - # and really should not be conflicting along the same path. - # I have chosen to make client challenges preferred - # as the client challenges should be able to be completely handled - # by this module and does not require outside config changes. - # (which may be costly) - - for i, msg in enumerate(challenge_msgs): - prefs = self.get_chall_pref(self.names[i]) - prefs.extend(self.auth.get_chall_pref(self.names[i])) - - paths.append(challenge.gen_challenge_path( - msg["challenges"], - prefs, - msg.get("combinations", []))) - - logging.info("Performing the following challenges:") - - auth_c, auth_i, client_c, client_i = self.challenge_factory( - self.names[i], msg["challenges"], paths[-1]) - - auth_chall.append(auth_c) - auth_idx.append(auth_i) - client_chall.append(client_c) - client_idx.append(client_i) - - responses.append(["null"] * len(msg["challenges"])) - - # Flatten list for client authenticator functions - client_resp = self.perform( - [chall for sublist in client_chall for chall in sublist]) - self._assign_responses(client_resp, client_idx, responses) - - # Flatten list for auth authenticator - auth_resp = self.auth.perform( - [chall for sublist in auth_chall for chall in sublist]) - self._assign_responses(auth_resp, auth_idx, responses) - - for i in range(len(paths)): - # If challenges failed to complete... zero them out - if not self._path_satisfied(responses[i], paths[i]): - responses[i] = None - auth_chall[i] = None - client_chall[i] = None - - logging.info( - "Configured Apache for challenges; waiting for verification...") - - return responses, auth_chall, client_chall - - # pylint: disable=no-self-use - def _assign_responses(self, flat_resp, idx_list, responses): - """Assign chall_response to appropriate places in response list. - - :param resp: responses from a challenge - :type resp: list of dicts - - :param list idx_list: respective challenges flat_resp satisfies - :param list responses: master list of responses - - """ - flat_index = 0 - # Every authorization_request message - for msg_num in range(len(responses)): - for idx in idx_list[msg_num]: - responses[msg_num][idx] = flat_resp[flat_index] - flat_index += 1 - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index fe4c95d3b..fcccb99dc 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -1,5 +1,6 @@ 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 @@ -27,7 +28,7 @@ class ClientAuthenticator(object): return ["recoveryToken"] def perform(self, chall_list): - """Perform client specific challenges.""" + """Perform client specific challenges for IAuthenticator""" responses = [] for chall in chall_list: if isinstance(chall, challenge_util.RecTokenChall): @@ -37,6 +38,7 @@ class ClientAuthenticator(object): 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) diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 086733bd8..504009f02 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -59,10 +59,10 @@ CHALLENGES = { } -def get_auth_challenges(): +def get_dv_challenges(): """Returns all auth challenges.""" return [chall for typ, chall in CHALLENGES.iteritems() - if typ in CONFIG.AUTH_CHALLENGES] + if typ in CONFIG.DV_CHALLENGES] def get_client_challenges(): @@ -83,7 +83,7 @@ def gen_combos(challs): combos = [] for i, chall in enumerate(challs): - if chall["type"] in CONFIG.AUTH_CHALLENGES: + if chall["type"] in CONFIG.DV_CHALLENGES: dv_chall.append(i) else: renewal_chall.append(i) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py new file mode 100644 index 000000000..1581f22e0 --- /dev/null +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -0,0 +1,444 @@ +"""Test auth_handler.py.""" +import unittest +import mock +import pkg_resources + +from letsencrypt.client.tests import acme_util + + +# 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) + msgs = [] + 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 = [] + msgs = [] + 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 + + def printout_handler(self): + print "***** Test Printout *****" + for dom in self.handler.domains: + print "Domain:", dom + print "***Challenge Messages***" + print self.handler.msgs[dom] + print "**responses**" + print self.handler.responses[dom] + print "**path**" + print self.handler.paths[dom] + print "**dv_c**" + for item in self.handler.dv_c[dom]: + print item + print "**client_c**" + for item in self.handler.client_c[dom]: + print item + + +# pylint: diable=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])) + + +translate = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + +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/client_test.py b/letsencrypt/client/tests/client_test.py deleted file mode 100644 index e22a95c64..000000000 --- a/letsencrypt/client/tests/client_test.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Test client.py.""" -import unittest -import mock -import pkg_resources - -from letsencrypt.client.tests import acme_util - - -class VerifyIdentityTest(unittest.TestCase): - """verify_identities test.""" - def setUp(self): - from letsencrypt.client.client import Client - from letsencrypt.client import CONFIG - - rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') - rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') - - auth_key = Client.Key(rsa256_file, rsa256_pem) - - self.mock_auth = mock.MagicMock(name='ApacheConfigurator') - self.mock_auth.get_chall_pref.return_value = ["dvsni"] - self.mock_auth.perform.side_effect = gen_auth_resp - - self.client = Client( - CONFIG.ACME_SERVER, ["0", "1", "2", "3", "4"], - auth_key, self.mock_auth, None) - self.client.perform = mock.MagicMock( - name='perform', side_effect=gen_auth_resp) - - def test_name1_dvsni1(self): - self.client.names = ["0"] - challenge = [acme_util.CHALLENGES["dvsni"]] - msgs = [acme_util.get_chall_msg("0", "nonce0", challenge)] - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 1) - self.assertEqual(len(responses[0]), 1) - - self.assertEqual("DvsniChall0", responses[0][0]) - self.assertEqual(len(auth_c), 1) - self.assertEqual(len(client_c), 1) - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 0) - - def test_name5_dvsni5(self): - challenge = [acme_util.CHALLENGES["dvsni"]] - msgs = [] - for i in range(5): - msgs.append( - acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge)) - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 5) - self.assertEqual(len(auth_c), 5) - self.assertEqual(len(client_c), 5) - # Each message contains 1 auth, 0 client - for i in range(5): - self.assertEqual(len(responses[i]), 1) - self.assertEqual(responses[i][0], "DvsniChall%d" % i) - self.assertEqual(len(auth_c[i]), 1) - self.assertEqual(len(client_c[i]), 0) - self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name1_auth(self, mock_chall_path): - self.client.names = ["0"] - - challenges = acme_util.get_auth_challenges() - combos = acme_util.gen_combos(challenges) - msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] - - path = gen_path(["simpleHttps"], challenges) - mock_chall_path.return_value = path - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 1) - self.assertEqual(len(responses[0]), len(challenges)) - self.assertEqual(len(auth_c), 1) - self.assertEqual(len(client_c), 1) - - self.assertEqual( - responses[0], - self._get_exp_response("0", path, challenges)) - - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 0) - self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name1_all(self, mock_chall_path): - self.client.names = ["0"] - - challenges = acme_util.get_challenges() - combos = acme_util.gen_combos(challenges) - msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] - - path = gen_path(["simpleHttps", "recoveryToken"], challenges) - mock_chall_path.return_value = path - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 1) - self.assertEqual(len(responses[0]), len(challenges)) - self.assertEqual(len(auth_c), 1) - self.assertEqual(len(client_c), 1) - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 1) - - self.assertEqual( - responses[0], - self._get_exp_response("0", path, challenges)) - self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") - self.assertEqual(type(client_c[0][0]).__name__, "RecTokenChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name5_all(self, mock_chall_path): - challenges = acme_util.get_challenges() - combos = acme_util.gen_combos(challenges) - msgs = [] - for i in range(5): - msgs.append( - acme_util.get_chall_msg( - str(i), "nonce%d" % i, challenges, combos)) - - path = gen_path(["dvsni", "recoveryContact"], challenges) - mock_chall_path.return_value = path - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 5) - for i in range(5): - self.assertEqual(len(responses[i]), len(challenges)) - self.assertEqual(len(auth_c), 5) - self.assertEqual(len(client_c), 5) - - for i in range(5): - self.assertEqual( - responses[i], self._get_exp_response(i, path, challenges)) - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 1) - - self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") - self.assertEqual(type(client_c[i][0]).__name__, "RecContactChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name5_mix(self, mock_chall_path): - paths = [] - msgs = [] - chosen_chall = [["dns"], - ["dvsni"], - ["simpleHttps", "proofOfPossession"], - ["simpleHttps"], - ["dns", "recoveryToken"]] - challenge_list = [acme_util.get_auth_challenges(), - [acme_util.CHALLENGES["dvsni"]], - acme_util.get_challenges(), - acme_util.get_auth_challenges(), - acme_util.get_challenges()] - - # Combos doesn't matter since I am overriding the gen_path function - for i in range(5): - paths.append(gen_path(chosen_chall[i], challenge_list[i])) - msgs.append( - acme_util.get_chall_msg( - str(i), "nonce%d" % i, challenge_list[i])) - - mock_chall_path.side_effect = paths - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 5) - self.assertEqual(len(auth_c), 5) - self.assertEqual(len(client_c), 5) - - for i in range(5): - resp = self._get_exp_response(i, paths[i], challenge_list[i]) - self.assertEqual(responses[i], resp) - self.assertEqual(len(auth_c[i]), 1) - self.assertEqual(len(client_c[i]), len(chosen_chall[i]) - 1) - - self.assertEqual(type(auth_c[0][0]).__name__, "DnsChall") - self.assertEqual(type(auth_c[1][0]).__name__, "DvsniChall") - self.assertEqual(type(auth_c[2][0]).__name__, "SimpleHttpsChall") - self.assertEqual(type(auth_c[3][0]).__name__, "SimpleHttpsChall") - self.assertEqual(type(auth_c[4][0]).__name__, "DnsChall") - - self.assertEqual(type(client_c[2][0]).__name__, "PopChall") - self.assertEqual(type(client_c[4][0]).__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 - - -class ClientPerformTest(unittest.TestCase): - """Test client perform function.""" - def setUp(self): - from letsencrypt.client.client import Client - from letsencrypt.client import CONFIG - - rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') - rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') - - auth_key = Client.Key(rsa256_file, rsa256_pem) - - self.client = Client( - CONFIG.ACME_SERVER, ["example.com"], auth_key, None, None) - self.client.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.client.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.client.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 LetsEncryptClientError - unexpected = DvsniChall("0", "rb64", "123", "invalid_key") - - self.assertRaises( - LetsEncryptClientError, self.client.perform, [unexpected]) - - -translate = {"dvsni": "DvsniChall", - "simpleHttps": "SimpleHttpsChall", - "dns": "DnsChall", - "recoveryToken": "RecTokenChall", - "recoveryContact": "RecContactChall", - "proofOfPossession": "PopChall"} - - -def gen_auth_resp(chall_list): - return ["%s%s" % (type(chall).__name__, chall.domain) - for chall in chall_list] - - -def gen_client_resp(chall): - return "%s%s" % (type(chall).__name__, chall.domain) - - -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() From 0dddcd1ffa64f5679d86d61a0df3288812c6dc6e Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 01:03:21 -0800 Subject: [PATCH 13/14] cleanup branch --- letsencrypt/client/auth_handler.py | 4 +- letsencrypt/client/challenge_util.py | 2 +- letsencrypt/client/client.py | 2 - letsencrypt/client/client_authenticator.py | 6 ++- letsencrypt/client/le_util.py | 4 +- .../client/tests/apache/parser_test.py | 3 +- letsencrypt/client/tests/auth_handler_test.py | 43 ++++++------------- 7 files changed, 22 insertions(+), 42 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 9ecb868ce..b5153842d 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -2,8 +2,6 @@ import logging import sys -import zope.component - from letsencrypt.client import acme from letsencrypt.client import CONFIG from letsencrypt.client import challenge_util @@ -404,7 +402,7 @@ def _find_dumb_path(challenges, preferences): # Add logic for a crappy server # Choose a DV path = [] - assert(len(preferences) == len(set(preferences))) + 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 diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 2341270cd..aef0d27e8 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -20,7 +20,7 @@ RecContactChall = collections.namedtuple( 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 +# 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") diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index bba729d35..ac3745f2f 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,7 +13,6 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import auth_handler -from letsencrypt.client import challenge_util from letsencrypt.client import client_authenticator from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util @@ -21,7 +20,6 @@ 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 recovery_token # it's weird to point to chocolate servers via raw IPv6 addresses, and diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index fcccb99dc..ac4406b28 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -1,3 +1,4 @@ +"""Client Authenticator""" import zope.interface from letsencrypt.client import challenge_util @@ -6,7 +7,7 @@ from letsencrypt.client import interfaces from letsencrypt.client import recovery_token class ClientAuthenticator(object): - """Authenticator for CONFIG.CLIENT_CHALLENGES. + """IAuthenticator for CONFIG.CLIENT_CHALLENGES. :ivar rec_token: Performs "recoveryToken" challenges :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` @@ -23,7 +24,8 @@ class ClientAuthenticator(object): """ self.rec_token = recovery_token.RecoveryToken(server) - def get_chall_pref(self, domain): # pylint: disable=no-member-use + # pylint: disable=unused-argument,no-self-use + def get_chall_pref(self, domain): """Return list of challenge preferences.""" return ["recoveryToken"] 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/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py index b7f1f6aa2..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 @@ -14,7 +15,7 @@ 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 index 1581f22e0..ee0a79895 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -1,11 +1,18 @@ """Test auth_handler.py.""" import unittest import mock -import pkg_resources 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.""" @@ -129,7 +136,6 @@ class SatisfyChallengesTest(unittest.TestCase): def test_name5_all(self, mock_chall_path): challenges = acme_util.get_challenges() combos = acme_util.gen_combos(challenges) - msgs = [] for i in range(5): self.handler.add_chall_msg( str(i), @@ -166,7 +172,6 @@ class SatisfyChallengesTest(unittest.TestCase): @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") def test_name5_mix(self, mock_chall_path): paths = [] - msgs = [] chosen_chall = [["dns"], ["dvsni"], ["simpleHttps", "proofOfPossession"], @@ -201,7 +206,8 @@ class SatisfyChallengesTest(unittest.TestCase): 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( + len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) self.assertEqual( type(self.handler.dv_c["0"][0].chall).__name__, "DnsChall") @@ -222,29 +228,12 @@ class SatisfyChallengesTest(unittest.TestCase): 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) + exp_resp[i] = TRANSLATE[challenges[i]["type"]] + str(domain) return exp_resp - def printout_handler(self): - print "***** Test Printout *****" - for dom in self.handler.domains: - print "Domain:", dom - print "***Challenge Messages***" - print self.handler.msgs[dom] - print "**responses**" - print self.handler.responses[dom] - print "**path**" - print self.handler.paths[dom] - print "**dv_c**" - for item in self.handler.dv_c[dom]: - print item - print "**client_c**" - for item in self.handler.client_c[dom]: - print item - -# pylint: diable=protected-access +# pylint: disable=protected-access class GetAuthorizationsTest(unittest.TestCase): def setUp(self): from letsencrypt.client.auth_handler import AuthHandler @@ -418,14 +407,6 @@ class PathSatisfiedTest(unittest.TestCase): self.assertFalse(self.handler._path_satisfied(dom[i])) -translate = {"dvsni": "DvsniChall", - "simpleHttps": "SimpleHttpsChall", - "dns": "DnsChall", - "recoveryToken": "RecTokenChall", - "recoveryContact": "RecContactChall", - "proofOfPossession": "PopChall"} - - def gen_auth_resp(chall_list): return ["%s%s" % (type(chall).__name__, chall.domain) for chall in chall_list] From ddbe8e7b29ad56ee09e968f943024611451afc29 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 01:22:24 -0800 Subject: [PATCH 14/14] Add more index specific documentation --- letsencrypt/client/apache/configurator.py | 13 +++++++++++-- letsencrypt/client/auth_handler.py | 15 +++++++++++++-- letsencrypt/client/recovery_token.py | 3 --- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 488730dbf..28e7537f3 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -942,8 +942,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param list chall_list: List of challenges to be fulfilled by configurator. - :returns: list of responses. A None response indicates the challenge - was not perfromed. + :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 """ @@ -953,6 +954,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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) sni_response = apache_dvsni.perform() @@ -960,6 +964,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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 @@ -968,6 +975,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def cleanup(self, chall_list): """Revert all challenges.""" self.chall_out -= len(chall_list) + + # If all of the challenges have been finished, clean up everything if self.chall_out <= 0: self.revert_challenge_config() self.restart() diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index b5153842d..afe85c71a 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -151,7 +151,14 @@ class AuthHandler(object): 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.""" + """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]: @@ -165,7 +172,11 @@ class AuthHandler(object): for i in self.paths[dom]) def _get_chall_pref(self, domain): - """Return list of challenge preferences.""" + """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)) diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py index b6111aea1..2da8a9c3f 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -16,10 +16,7 @@ class RecoveryToken(object): Based on draft-barnes-acme, section 6.4. """ - # zope.interface.implements(interfaces.IChallenge) - def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): - # super(RecoveryToken, self).__init__() self.token_dir = os.path.join(direc, server) def perform(self, chall):