mirror of
https://github.com/certbot/certbot.git
synced 2026-05-28 04:34:11 -04:00
Initial challenge refactor/allow multiple names
This commit is contained in:
parent
05d803ddd3
commit
f089449bf2
11 changed files with 196 additions and 64 deletions
|
|
@ -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"])]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue