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)