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