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