Initial challenge refactor/allow multiple names

This commit is contained in:
James Kasten 2015-01-06 01:57:07 -08:00
parent 05d803ddd3
commit f089449bf2
11 changed files with 196 additions and 64 deletions

View file

@ -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"])]

View file

@ -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. <VirtualHost>, <IfModule> 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

View file

@ -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 = "<IfModule mod_ssl.c>\n"
@ -134,9 +134,9 @@ class ApacheDVSNI(object):
config_text += "</IfModule>\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

View file

@ -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)

View file

@ -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)):

View file

@ -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.

View file

@ -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."""

View file

@ -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.

View file

@ -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()

View file

@ -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)

View file

@ -150,8 +150,7 @@ def choose_names(installer):
code, names = zope.component.getUtility(
interfaces.IDisplay).filter_names(get_all_names(installer))
if code == display.OK and names:
# TODO: Allow multiple names once it is setup
return [names[0]]
return names
else:
sys.exit(0)