Merge branch 'dvsni_cleanup'

This commit is contained in:
James Kasten
2015-01-13 01:24:55 -08:00
34 changed files with 1825 additions and 558 deletions
+6
View File
@@ -10,6 +10,12 @@
.. automodule:: letsencrypt.client.apache.configurator
:members:
:mod:`letsencrypt.client.apache.dvsni`
=============================================
.. automodule:: letsencrypt.client.apache.dvsni
:members:
:mod:`letsencrypt.client.apache.obj`
====================================
+5
View File
@@ -0,0 +1,5 @@
:mod:`letsencrypt.client.auth_handler`
--------------------------------
.. automodule:: letsencrypt.client.auth_handler
:members:
+5
View File
@@ -0,0 +1,5 @@
:mod:`letsencrypt.client.client_authenticator`
--------------------------------
.. automodule:: letsencrypt.client.client_authenticator
:members:
+5
View File
@@ -0,0 +1,5 @@
:mod:`letsencrypt.client.recovery_token`
--------------------------------------------------
.. automodule:: letsencrypt.client.recovery_token
:members:
@@ -1,5 +0,0 @@
:mod:`letsencrypt.client.recovery_token_challenge`
--------------------------------------------------
.. automodule:: letsencrypt.client.recovery_token_challenge
:members:
+9 -11
View File
@@ -30,9 +30,10 @@ IN_PROGRESS_DIR = os.path.join(BACKUP_DIR, "IN_PROGRESS/")
"""Directory used before a permanent checkpoint is finalized"""
CERT_KEY_BACKUP = os.path.join(WORK_DIR, "keys-certs/")
"""Directory where all certificates/keys are stored.
"""Directory where all certificates/keys are stored. Used for easy revocation"""
Used for easy revocation"""
REV_TOKENS_DIR = os.path.join(WORK_DIR, "revocation_tokens/")
"""Directory where all revocation tokens are saved."""
KEY_DIR = os.path.join(SERVER_ROOT, "ssl/")
"""Where all keys should be stored"""
@@ -47,9 +48,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."""
@@ -59,15 +57,15 @@ CHAIN_PATH = CERT_DIR + "chain-letsencrypt.pem"
INVALID_EXT = ".acme.invalid"
"""Invalid Extension"""
# Challenge Information
CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"]
"""Challenge Preferences Dict for currently supported challenges"""
EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])]
"""Mutually Exclusive Challenges - only solve 1"""
CONFIG_CHALLENGES = frozenset(["dvsni", "simpleHttps"])
"""These are challenges that must be solved by a Configurator object"""
DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"])
"""These are challenges that must be solved by an Authenticator object"""
CLIENT_CHALLENGES = frozenset(
["recoveryToken", "recoveryContact", "proofOfPossession"])
"""These are challenges that are handled by client.py"""
# Challenge Constants
S_SIZE = 32
+46 -179
View File
@@ -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
@@ -17,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
@@ -117,6 +117,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 +127,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.
@@ -804,7 +801,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
def is_site_enabled(self, avail_fp):
"""Checks to see if the given site is enabled.
.. todo:: fix hardcoded sites-enabled
.. todo:: fix hardcoded sites-enabled, check os.path.samefile
:param str avail_fp: Complete file path of available site
@@ -814,7 +811,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
enabled_dir = os.path.join(self.parser.root, "sites-enabled/")
for entry in os.listdir(enabled_dir):
if os.path.realpath(enabled_dir + entry) == avail_fp:
if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp:
return True
return False
@@ -929,190 +926,60 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
###########################################################################
# Challenges Section
###########################################################################
# pylint: disable=no-self-use, unused-argument
def get_chall_pref(self, domain):
"""Return list of challenge preferences."""
# TODO: Change list_sni_tuple to namedtuple. Also include key within tuple.
# This allows the keys to be different for each SNI challenge
return ["dvsni"]
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 list chall_list: List of challenges to be
fulfilled by configurator.
:returns: list of responses. All responses are returned in the same
order as received by the perform function. A None response
indicates the challenge was not perfromed.
:rtype: list
"""
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.DvsniChall):
# Currently also have dvsni hold associated index
# of the challenge. This helps to put all of the responses back
# together when they are all complete.
apache_dvsni.add_chall(chall, i)
def dvsni_perform(self, chall_dict):
"""Perform a DVSNI challenge.
`chall_dict` composed of:
list_sni_tuple:
List of tuples with form `(name, r, nonce)`, where
`name` (`str`), `r` (base64 `str`), `nonce` (hex `str`)
dvsni_key:
DVSNI 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 not ("list_sni_tuple" in chall_dict and "dvsni_key" 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 tup in chall_dict["list_sni_tuple"]:
vhost = self.choose_virtual_host(tup[0])
if vhost is None:
logging.error(
"No vhost exists with servername or alias of: %s", tup[0])
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 tup in chall_dict["list_sni_tuple"]:
cert_path = self.dvsni_get_cert_file(tup[2])
self.register_file_creation(cert_path)
s_b64 = challenge_util.dvsni_gen_cert(
cert_path, tup[0], tup[1], tup[2], chall_dict["dvsni_key"])
responses.append({"type": "dvsni", "s": s_b64})
# Setup the configuration
self.dvsni_mod_config(chall_dict["list_sni_tuple"],
chall_dict["dvsni_key"],
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()
# Go through all of the challenges and assign them to the proper place
# in the responses return value. All responses must be in the same order
# as the original challenges.
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.chall_out -= len(chall_list)
self.revert_challenge_config()
self.restart()
# TODO: Variable names
def dvsni_mod_config(self, list_sni_tuple, dvsni_key,
ll_addrs):
"""Modifies Apache config files to include challenge vhosts.
Result: Apache config includes virtual servers for issued challs
:param list list_sni_tuple: list of tuples with the form
`(addr, y, nonce)`, where `addr` is `str`, `y` is `bytearray`,
and nonce is hex `str`
:param dvsni_key: DVSNI key
:type dvsni_key: :class:`letsencrypt.client.client.Client.Key`
: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 = "<IfModule mod_ssl.c>\n"
for idx, lis in enumerate(ll_addrs):
config_text += self.get_config_text(
list_sni_tuple[idx][2], lis, dvsni_key.file)
config_text += "</IfModule>\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 ("<VirtualHost " + ips + ">\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"
"</VirtualHost>\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"
# If all of the challenges have been finished, clean up everything
if self.chall_out <= 0:
self.revert_challenge_config()
self.restart()
def enable_mod(mod_name):
+191
View File
@@ -0,0 +1,191 @@
"""ApacheDVSNI"""
import logging
import os
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 DvsniChall 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.DvsniChall`
:param list indicies: Meant to hold indices of challenges in a
larger array. ApacheDvsni is capable of solving many challenges
at once which causes an indexing issue within ApacheConfigurator
who must return all responses in order. Imagine ApacheConfigurator
maintaining state about where all of the SimpleHttps Challenges,
Dvsni Challenges belong in the response array. This is an optional
utility.
:param str challenge_conf: location of the challenge config file
"""
def __init__(self, config):
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):
"""Add challenge to DVSNI object to perform at once.
:param chall: DVSNI challenge info
:type chall: :class:`letsencrypt.client.challenge_util.DvsniChall`
: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 None
# 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 _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 = "<IfModule mod_ssl.c>\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 += "</IfModule>\n"
self._conf_include_check(self.config.parser.loc["default"])
self.config.register_file_creation(True, self.challenge_conf)
with open(self.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"), self.challenge_conf)) == 0:
# print "Including challenge virtual host(s)"
self.config.parser.add_dir(parser.get_aug_path(main_config),
"Include", self.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 ("<VirtualHost " + ips + ">\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"
"</VirtualHost>\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"
+1
View File
@@ -225,6 +225,7 @@ class ApacheParser(object):
:rtype: str
"""
# Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py
regex = ""
for letter in clean_fn_match:
if letter == '.':
+2 -2
View File
@@ -342,7 +342,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:
@@ -399,7 +399,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)
+436
View File
@@ -0,0 +1,436 @@
"""ACME AuthHandler."""
import logging
import sys
from letsencrypt.client import acme
from letsencrypt.client import CONFIG
from letsencrypt.client import challenge_util
from letsencrypt.client import errors
class AuthHandler(object):
"""ACME Authorization Handler for a client.
:ivar dv_auth: Authenticator capable of solving CONFIG.DV_CHALLENGES
:type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
:ivar client_auth: Authenticator capable of solving CONFIG.CLIENT_CHALLENGES
:type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
:ivar network: Network object for sending and receiving authorization
messages
:type network: :class:`letsencrypt.client.network.Network`
:ivar list domains: list of str domains to get authorization
:ivar dict authkey: Authorized Keys for each domain.
values are of type :class:`letsencrypt.client.client.Client.Key`
:ivar dict responses: keys: domain, values: list of dict responses
:ivar dict msgs: ACME Challenge messages with domain as a key
:ivar dict paths: optimal path for authorization. eg. paths[domain]
:ivar dict dv_c: Keys - domain, Values are DV challenges in the form of
:class:`letsencrypt.client.challenge_util.IndexedChall`
:ivar dict client_c: Keys - domain, Values are Client challenges in the form
of :class:`letsencrypt.client.challenge_util.IndexedChall`
"""
def __init__(self, dv_auth, client_auth, network):
self.dv_auth = dv_auth
self.client_auth = client_auth
self.network = network
self.domains = []
self.authkey = dict()
self.responses = dict()
self.msgs = dict()
self.paths = dict()
self.dv_c = dict()
self.client_c = dict()
def add_chall_msg(self, domain, msg, authkey):
"""Add a challenge message to the AuthHandler.
:param str domain: domain for authorization
:param dict msg: ACME challenge message
:param authkey: authorized key for the challenge
:type authkey: :class:`letsencrypt.client.client.Client.Key`
"""
if domain in self.domains:
raise errors.LetsEncryptAuthHandlerError(
"Multiple ACMEChallengeMessages for the same domain "
"is not supported.")
self.domains.append(domain)
self.responses[domain] = ["null"] * len(msg["challenges"])
self.msgs[domain] = msg
self.authkey[domain] = authkey
def get_authorizations(self):
"""Retreive all authorizations for challenges.
:raises LetsEncryptAuthHandlerError: If unable to retrieve all
authorizations
"""
progress = True
while self.msgs and progress:
progress = False
self._satisfy_challenges()
delete_list = []
for dom in self.domains:
if self._path_satisfied(dom):
self.acme_authorization(dom)
delete_list.append(dom)
# This avoids modifying while iterating over the list
if delete_list:
self._cleanup_state(delete_list)
progress = True
if not progress:
raise errors.LetsEncryptAuthHandlerError(
"Unable to solve challenges for requested names.")
def acme_authorization(self, domain):
"""Handle ACME "authorization" phase.
:param str domain: domain that is requesting authorization
:returns: ACME "authorization" message.
:rtype: dict
"""
try:
return self.network.send_and_receive_expected(
acme.authorization_request(
self.msgs[domain]["sessionID"],
domain,
self.msgs[domain]["nonce"],
self.responses[domain],
self.authkey[domain].pem),
"authorization")
except errors.LetsEncryptClientError as err:
logging.fatal(str(err))
logging.fatal(
"Failed Authorization procedure - cleaning up challenges")
sys.exit(1)
finally:
self._cleanup_challenges(domain)
def _satisfy_challenges(self):
"""Attempt to satisfy all saved challenge messages."""
logging.info("Performing the following challenges:")
for dom in self.domains:
self.paths[dom] = gen_challenge_path(
self.msgs[dom]["challenges"],
self._get_chall_pref(dom),
self.msgs[dom].get("combinations", None))
self.dv_c[dom], self.client_c[dom] = self._challenge_factory(
dom, self.paths[dom])
# Flatten challs for authenticator functions and remove index
# Order is important here as we will not expose the outside
# Authenticator to our own indices.
flat_client = []
flat_auth = []
for dom in self.domains:
flat_client.extend(ichall.chall for ichall in self.client_c[dom])
flat_auth.extend(ichall.chall for ichall in self.dv_c[dom])
client_resp = self.client_auth.perform(flat_client)
dv_resp = self.dv_auth.perform(flat_auth)
logging.info("Ready for verification...")
# Assemble Responses
self._assign_responses(client_resp, self.client_c)
self._assign_responses(dv_resp, self.dv_c)
def _assign_responses(self, flat_list, ichall_dict):
"""Assign responses from flat_list back to the IndexedChall dicts.
:param list flat_list: flat_list of responses from an IAuthenticator
:param dict ichall_dict: Master dict mapping all domains to a list of
their associated 'client' and 'dv' IndexedChallenges, or their
:class:`letsencrypt.client.challenge_util.IndexedChall` list
"""
flat_index = 0
for dom in self.domains:
for ichall in ichall_dict[dom]:
self.responses[dom][ichall.index] = flat_list[flat_index]
flat_index += 1
def _path_satisfied(self, dom):
"""Returns whether a path has been completely satisfied."""
return all(
None != self.responses[dom][i] and "null" != self.responses[dom][i]
for i in self.paths[dom])
def _get_chall_pref(self, domain):
"""Return list of challenge preferences.
:param str domain: domain for which you are requesting preferences
"""
chall_prefs = []
chall_prefs.extend(self.client_auth.get_chall_pref(domain))
chall_prefs.extend(self.dv_auth.get_chall_pref(domain))
return chall_prefs
def _cleanup_challenges(self, domain):
"""Cleanup configuration challenges
:param str domain: domain for which to clean up challenges
"""
logging.info("Cleaning up challenges...")
self.dv_auth.cleanup(self.dv_c[domain])
self.client_auth.cleanup(self.client_c[domain])
def _cleanup_state(self, delete_list):
"""Cleanup state after an authorization is received.
:param list delete_list: list of domains in str form
"""
for domain in delete_list:
del self.msgs[domain]
del self.responses[domain]
del self.paths[domain]
del self.authkey[domain]
del self.client_c[domain]
del self.dv_c[domain]
self.domains.remove(domain)
def _challenge_factory(self, domain, path):
"""Construct Namedtuple Challenges
:param str domain: domain of the enrollee
:param list path: List of indices from `challenges`.
:returns: dv_chall, list of
:class:`letsencrypt.client.challenge_util.IndexedChall`
client_chall, list of
:class:`letsencrypt.client.challenge_util.IndexedChall`
:rtype: tuple
:raises errors.LetsEncryptClientError: If Challenge type is not
recognized
"""
challenges = self.msgs[domain]["challenges"]
dv_chall = []
client_chall = []
for index in self.paths[domain]:
chall = challenges[index]
# Authenticator Challenges
if chall["type"] in CONFIG.DV_CHALLENGES:
dv_chall.append(challenge_util.IndexedChall(
self._construct_dv_chall(chall, domain), index))
# Client Challenges
elif chall["type"] in CONFIG.CLIENT_CHALLENGES:
client_chall.append(challenge_util.IndexedChall(
self._construct_client_chall(chall, domain), index))
else:
raise errors.LetsEncryptClientError(
"Received unrecognized challenge of type: "
"%s" % chall["type"])
return dv_chall, client_chall
def _construct_dv_chall(self, chall, domain):
"""Construct Auth Type Challenges.
:param dict chall: Single challenge
:param str domain: challenge's domain
:returns: challenge_util named tuple Chall object
:rtype: `collections.namedtuple`
:raises errors.LetsEncryptClientError: If unimplemented challenge exists
"""
if chall["type"] == "dvsni":
logging.info(" DVSNI challenge for name %s.", domain)
return challenge_util.DvsniChall(
domain, str(chall["r"]), str(chall["nonce"]),
self.authkey[domain])
elif chall["type"] == "simpleHttps":
logging.info(" SimpleHTTPS challenge for name %s.", domain)
return challenge_util.SimpleHttpsChall(
domain, str(chall["token"]), self.authkey[domain])
elif chall["type"] == "dns":
logging.info(" DNS challenge for name %s.", domain)
return challenge_util.DnsChall(
domain, str(chall["token"]), self.authkey[domain])
else:
raise errors.LetsEncryptClientError(
"Unimplemented Auth Challenge: %s" % chall["type"])
def _construct_client_chall(self, chall, domain):
"""Construct Client Type Challenges.
:param dict chall: Single challenge
:param str domain: challenge's domain
:returns: challenge_util named tuple Chall object
:rtype: `collections.namedtuple`
:raises errors.LetsEncryptClientError: If unimplemented challenge exists
"""
if chall["type"] == "recoveryToken":
logging.info(" Recovery Token Challenge for name: %s.", domain)
return challenge_util.RecTokenChall(domain)
elif chall["type"] == "recoveryContact":
logging.info(" Recovery Contact Challenge for name: %s.", domain)
return challenge_util.RecContactChall(
domain,
chall.get("activationURL", None),
chall.get("successURL", None),
chall.get("contact", None))
elif chall["type"] == "proofOfPossession":
logging.info(" Proof-of-Possession Challenge for name: "
"%s", domain)
return challenge_util.PopChall(
domain, chall["alg"], chall["nonce"], chall["hints"])
else:
raise errors.LetsEncryptClientError(
"Unimplemented Client Challenge: %s" % chall["type"])
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...
Example: Do you have the recovery key?
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param list preferences: List of challenge preferences for domain
:param combos: A collection of sets of challenges from ACME
"challenge" server message ("combinations"), each of which would
be sufficient to prove possession of the identifier.
:type combos: list or None
:returns: List of indices from `challenges`.
:rtype: list
"""
if combos:
return _find_smart_path(challenges, preferences, combos)
else:
return _find_dumb_path(challenges, preferences)
def _find_smart_path(challenges, preferences, combos):
"""Find challenge path with server hints.
Can be called if combinations is included. Function uses a simple
ranking system to choose the combo with the lowest cost.
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param combos: A collection of sets of challenges from ACME
"challenge" server message ("combinations"), each of which would
be sufficient to prove possession of the identifier.
:type combos: list or None
:returns: List of indices from `challenges`.
:rtype: list
"""
chall_cost = {}
max_cost = 0
for i, chall in enumerate(preferences):
chall_cost[chall] = i
max_cost += i
best_combo = []
# Set above completing all of the available challenges
best_combo_cost = max_cost + 1
combo_total = 0
for combo in combos:
for challenge_index in combo:
combo_total += chall_cost.get(challenges[
challenge_index]["type"], max_cost)
if combo_total < best_combo_cost:
best_combo = combo
best_combo_cost = combo_total
combo_total = 0
if not best_combo:
logging.fatal("Client does not support any combination of "
"challenges to satisfy ACME server")
sys.exit(22)
return best_combo
def _find_dumb_path(challenges, preferences):
"""Find challenge path without server hints.
Should be called if the combinations hint is not included by the
server. This function returns the best path that does not contain
multiple mutually exclusive challenges.
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param list preferences: A list of preferences representing the
challenge type found within the ACME spec. Each challenge type
can only be listed once.
:returns: List of indices from `challenges`.
:rtype: list
"""
# Add logic for a crappy server
# Choose a DV
path = []
assert len(preferences) == len(set(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)):
path.append((i, offered_challenge["type"]))
return [i for (i, _) in path]
def is_preferred(offered_challenge_type, path):
"""Return whether or not the challenge is preferred in path."""
for _, challenge_type in path:
for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES:
# Second part is in case we eventually allow multiple names
# to be challenges at the same time
if (challenge_type in mutually_exclusive and
offered_challenge_type in mutually_exclusive and
challenge_type != offered_challenge_type):
return False
return True
-117
View File
@@ -1,117 +0,0 @@
"""ACME challenge."""
import logging
import sys
from letsencrypt.client import CONFIG
def gen_challenge_path(challenges, combos=None):
"""Generate a plan to get authority over the identity.
.. todo:: Make sure that the challenges are feasible...
Example: Do you have the recovery key?
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param combos: A collection of sets of challenges from ACME
"challenge" server message ("combinations"), each of which would
be sufficient to prove possession of the identifier.
:type combos: list or None
:returns: List of indices from `challenges`.
:rtype: list
"""
if combos:
return _find_smart_path(challenges, combos)
else:
return _find_dumb_path(challenges)
def _find_smart_path(challenges, combos):
"""Find challenge path with server hints.
Can be called if combinations is included. Function uses a simple
ranking system to choose the combo with the lowest cost.
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param combos: A collection of sets of challenges from ACME
"challenge" server message ("combinations"), each of which would
be sufficient to prove possession of the identifier.
:type combos: list or None
:returns: List of indices from `challenges`.
:rtype: list
"""
chall_cost = {}
max_cost = 0
for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES):
chall_cost[chall] = i
max_cost += i
best_combo = []
# Set above completing all of the available challenges
best_combo_cost = max_cost + 1
combo_total = 0
for combo in combos:
for challenge_index in combo:
combo_total += chall_cost.get(challenges[
challenge_index]["type"], max_cost)
if combo_total < best_combo_cost:
best_combo = combo
best_combo_cost = combo_total
combo_total = 0
if not best_combo:
logging.fatal("Client does not support any combination of "
"challenges to satisfy ACME server")
sys.exit(22)
return best_combo
def _find_dumb_path(challenges):
"""Find challenge path without server hints.
Should be called if the combinations hint is not included by the
server. This function returns the best path that does not contain
multiple mutually exclusive challenges.
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:returns: List of indices from `challenges`.
:rtype: list
"""
# Add logic for a crappy server
# Choose a DV
path = []
for pref_c in CONFIG.CHALLENGE_PREFERENCES:
for i, offered_challenge in enumerate(challenges):
if (pref_c == offered_challenge["type"] and
is_preferred(offered_challenge["type"], path)):
path.append((i, offered_challenge["type"]))
return [i for (i, _) in path]
def is_preferred(offered_challenge_type, path):
for _, challenge_type in path:
for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES:
# Second part is in case we eventually allow multiple names
# to be challenges at the same time
if (challenge_type in mutually_exclusive and
offered_challenge_type in mutually_exclusive and
challenge_type != offered_challenge_type):
return False
return True
+16
View File
@@ -1,4 +1,5 @@
"""Challenge specific utility functions."""
import collections
import hashlib
from Crypto import Random
@@ -7,6 +8,21 @@ from letsencrypt.client import CONFIG
from letsencrypt.client import crypto_util
from letsencrypt.client import le_util
# Authenticator Challenges
DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key")
SimpleHttpsChall = collections.namedtuple(
"SimpleHttpsChall", "domain, token, key")
DnsChall = collections.namedtuple("DnsChall", "domain, token, key")
# Client Challenges
RecContactChall = collections.namedtuple(
"RecContactChall", "domain, a_url, s_url, contact")
RecTokenChall = collections.namedtuple("RecTokenChall", "domain")
PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints")
# Helper Challenge Wrapper - Can be used to maintain the proper position of
# the response within a larger challenge list
IndexedChall = collections.namedtuple("IndexedChall", "chall, index")
# DVSNI Challenge functions
def dvsni_gen_cert(filepath, name, r_b64, nonce, key):
+36 -172
View File
@@ -12,7 +12,8 @@ import M2Crypto
import zope.component
from letsencrypt.client import acme
from letsencrypt.client import challenge
from letsencrypt.client import auth_handler
from letsencrypt.client import client_authenticator
from letsencrypt.client import CONFIG
from letsencrypt.client import crypto_util
from letsencrypt.client import errors
@@ -38,27 +39,40 @@ class Client(object):
:ivar authkey: Authorization Key
:type authkey: :class:`letsencrypt.client.client.Client.Key`
:ivar auth: Object that supports the IAuthenticator interface.
:type auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
:ivar auth_handler: Object that supports the IAuthenticator interface.
auth_handler contains both a dv_authenticator and a client_authenticator
:type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler`
:ivar installer: Object supporting the IInstaller interface.
:type installer: :class:`letsencrypt.client.interfaces.IInstaller`
"""
zope.interface.implements(interfaces.IAuthenticator)
Key = collections.namedtuple("Key", "file pem")
CSR = collections.namedtuple("CSR", "file data form")
def __init__(self, server, names, authkey, auth, installer):
"""Initialize a client."""
def __init__(self, server, names, authkey, dv_auth, installer):
"""Initialize a client.
:param str server: CA server to contact
:param dv_auth: IAuthenticator Interface that can solve the
CONFIG.DV_CHALLENGES
:type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
"""
self.network = network.Network(server)
self.names = names
self.authkey = authkey
sanity_check_names([server] + names)
self.auth = auth
self.installer = installer
client_auth = client_authenticator.ClientAuthenticator(server)
self.auth_handler = auth_handler.AuthHandler(
dv_auth, client_auth, self.network)
def obtain_certificate(self, csr,
cert_path=CONFIG.CERT_PATH,
chain_path=CONFIG.CHAIN_PATH):
@@ -76,13 +90,12 @@ class Client(object):
"""
# Request Challenges
challenge_msg = self.acme_challenge()
for name in self.names:
self.auth_handler.add_chall_msg(
name, self.acme_challenge(name), self.authkey)
# Perform Challenges
responses, challenge_objs = self.verify_identity(challenge_msg)
# Get Authorization
self.acme_authorization(challenge_msg, challenge_objs, responses)
# Perform Challenges/Get Authorizations
self.auth_handler.get_authorizations()
# Retrieve certificate
certificate_dict = self.acme_certificate(csr.data)
@@ -95,43 +108,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")
def acme_authorization(self, challenge_msg, chal_objs, responses):
"""Handle ACME "authorization" phase.
:param dict challenge_msg: ACME "challenge" message.
:param chal_objs: TODO - this will be a new object...
:param responses: TODO
:returns: ACME "authorization" message.
:rtype: dict
"""
try:
return self.network.send_and_receive_expected(
acme.authorization_request(
challenge_msg["sessionID"], self.names[0],
challenge_msg["nonce"], responses, self.authkey.pem),
"authorization")
except errors.LetsEncryptClientError as err:
logging.fatal(str(err))
logging.fatal(
"Failed Authorization procedure - cleaning up challenges")
sys.exit(1)
finally:
self.cleanup_challenges(chal_objs)
acme.challenge_request(domain), "challenge")
def acme_certificate(self, csr_der):
"""Handle ACME "certificate" phase.
@@ -242,78 +227,6 @@ class Client(object):
# # TODO enable OCSP Stapling
# continue
def cleanup_challenges(self, challenges):
"""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
def verify_identity(self, challenge_msg):
"""Verify identity.
:param dict challenge_msg: ACME "challenge" message.
:returns: TODO
:rtype: dict
"""
path = challenge.gen_challenge_path(
challenge_msg["challenges"], challenge_msg.get("combinations", []))
logging.info("Performing the following challenges:")
# 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
challenge_objs, indices = 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)
logging.info(
"Configured Apache for challenges; waiting for verification...")
return responses, challenge_objs
# 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
: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
def store_cert_key(self, cert_file, encrypt=False):
"""Store certificate key.
@@ -391,67 +304,18 @@ class Client(object):
vhost.add(host)
return vhost
def challenge_factory(self, name, challenges, path):
"""
:param name: TODO
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param list path: List of indices from `challenges`.
:returns: A pair of TODO
:rtype: tuple
"""
sni_todo = []
# Since a single invocation of SNI challenge can satisfy multiple
# challenges. We must keep track of all the challenges it satisfies
sni_satisfies = []
challenge_objs = []
challenge_obj_indices = []
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((str(name), str(chall["r"]),
str(chall["nonce"])))
elif chall["type"] == "recoveryToken":
logging.info("\tRecovery Token Challenge for name: %s.", name)
challenge_obj_indices.append(index)
challenge_objs.append({
type: "recoveryToken",
})
else:
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",
"list_sni_tuple": sni_todo,
"dvsni_key": self.authkey,
})
challenge_obj_indices.append(sni_satisfies)
logging.debug(sni_todo)
return challenge_objs, challenge_obj_indices
def validate_key_csr(privkey, csr, names):
def validate_key_csr(privkey, csr):
"""Validate CSR and key files.
Verifies that the client key and csr arguments are valid and
correspond to one another.
Verifies that the client key and csr arguments are valid and correspond to
one another. This does not currently check the names in the CSR.
:param privkey: Key associated with CSR
:type privkey: :class:`letsencrypt.client.client.Client.Key`
:param csr: CSR
:type csr: :class:`letsencrypt.client.client.Client.CSR`
:raises LetsEncryptClientError: if validation fails
@@ -0,0 +1,48 @@
"""Client Authenticator"""
import zope.interface
from letsencrypt.client import challenge_util
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import recovery_token
class ClientAuthenticator(object):
"""IAuthenticator for CONFIG.CLIENT_CHALLENGES.
:ivar rec_token: Performs "recoveryToken" challenges
:type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken`
"""
zope.interface.implements(interfaces.IAuthenticator)
# This will have an installer soon for get_key/cert purposes
def __init__(self, server):
"""Initialize Client Authenticator.
:param str server: ACME CA Server
"""
self.rec_token = recovery_token.RecoveryToken(server)
# pylint: disable=unused-argument,no-self-use
def get_chall_pref(self, domain):
"""Return list of challenge preferences."""
return ["recoveryToken"]
def perform(self, chall_list):
"""Perform client specific challenges for IAuthenticator"""
responses = []
for chall in chall_list:
if isinstance(chall, challenge_util.RecTokenChall):
responses.append(self.rec_token.perform(chall))
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
return responses
def cleanup(self, chall_list):
"""Cleanup call for IAuthenticator."""
for chall in chall_list:
if isinstance(chall, challenge_util.RecTokenChall):
self.rec_token.cleanup(chall)
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
+9 -1
View File
@@ -5,8 +5,16 @@ class LetsEncryptClientError(Exception):
"""Generic Let's Encrypt client error."""
class LetsEncryptAuthHandlerError(LetsEncryptClientError):
"""Let's Encrypt Auth Handler error."""
class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError):
"""Let's Encrypt Client Authenticator Error."""
class LetsEncryptConfiguratorError(LetsEncryptClientError):
"""Let's Encrypt configurator error."""
"""Let's Encrypt Configurator error."""
class LetsEncryptDvsniError(LetsEncryptConfiguratorError):
+23 -9
View File
@@ -11,22 +11,36 @@ class IAuthenticator(zope.interface.Interface):
ability to perform challenges and attain a certificate.
"""
def perform(chall_dict):
"""Perform the given challenge"""
def get_chall_pref(domain):
"""Return list of challenge preferences.
def cleanup():
:param str domain: Domain for which challenge preferences are sought.
:returns: list of strings with the most preferred challenges first.
:rtype: list
"""
def perform(chall_list):
"""Perform the given challenge.
: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):
"""Perform the challenge.
:param bool quiet: TODO
"""
def perform():
"""Perform the challenge."""
def generate_response():
"""Generate response."""
+2 -2
View File
@@ -62,9 +62,9 @@ def unique_file(default_name, mode=0o777):
f_parsed = os.path.splitext(default_name)
while 1:
try:
fd = os.open(
file_d = os.open(
default_name, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode)
return os.fdopen(fd, 'w'), default_name
return os.fdopen(file_d, 'w'), default_name
except OSError:
pass
default_name = f_parsed[0] + '_' + str(count) + f_parsed[1]
+3
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.
+79
View File
@@ -0,0 +1,79 @@
"""Recovery Token Identifier Validation Challenge."""
import errno
import os
import zope.component
# import zope.interface
from letsencrypt.client import CONFIG
from letsencrypt.client import le_util
from letsencrypt.client import interfaces
class RecoveryToken(object):
"""Recovery Token Identifier Validation Challenge.
Based on draft-barnes-acme, section 6.4.
"""
def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR):
self.token_dir = os.path.join(direc, server)
def perform(self, chall):
"""Perform the Recovery Token Challenge.
:param chall: Recovery Token Challenge
:type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall`
:returns: response
:rtype: dict
"""
token_fp = os.path.join(self.token_dir, chall.domain)
if os.path.isfile(token_fp):
with open(token_fp) as token_fd:
return self.generate_response(token_fd.read())
cancel, token = zope.component.getUtility(
interfaces.IDisplay).generic_input(
"%s - Input Recovery Token: " % chall.domain)
if cancel != 1:
return self.generate_response(token)
return None
def cleanup(self, chall):
"""Cleanup the saved recovery token if it exists.
:param chall: Recovery Token Challenge
:type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall`
"""
try:
os.remove(os.path.join(self.token_dir, chall.domain))
except OSError as err:
if err.errno != errno.ENOENT:
raise
def generate_response(self, token): # pylint: disable=no-self-use
"""Generate json response."""
return {
"type": "recoveryToken",
"token": token,
}
def requires_human(self, domain):
"""Indicates whether or not domain can be auto solved."""
return not os.path.isfile(os.path.join(self.token_dir, domain))
def store_token(self, domain, token):
"""Store token for later automatic use.
:param str domain: domain associated with the token
:param str token: token from authorization
"""
le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid())
with open(os.path.join(self.token_dir, domain), 'w') as token_fd:
token_fd.write(str(token))
@@ -1,37 +0,0 @@
"""Recovery Token Identifier Validation Challenge.
.. note:: This challenge has not been implemented into the project yet
"""
import zope.component
import zope.interface
from letsencrypt.client import interfaces
class RecoveryToken(object):
"""Recovery Token Identifier Validation Challenge.
Based on draft-barnes-acme, section 6.4.
"""
zope.interface.implements(interfaces.IChallenge)
def __init__(self):
super(RecoveryToken, self).__init__()
self.token = ""
def perform(self, quiet=True):
cancel, self.token = zope.component.getUtility(
interfaces.IDisplay).generic_input(
"Please Input Recovery Token: ")
return cancel != 1
def cleanup(self):
pass
def generate_response(self):
return {
"type": "recoveryToken",
"token": self.token,
}
+112
View File
@@ -0,0 +1,112 @@
"""Class helps construct valid ACME messages for testing."""
from letsencrypt.client import CONFIG
CHALLENGES = {
"simpleHttps":
{
"type": "simpleHttps",
"token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA"
},
"dvsni":
{
"type": "dvsni",
"r": "Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI",
"nonce": "a82d5ff8ef740d12881f6d3c2277ab2e"
},
"dns":
{
"type": "dns",
"token": "17817c66b60ce2e4012dfad92657527a"
},
"recoveryContact":
{
"type": "recoveryContact",
"activationURL": "https://example.ca/sendrecovery/a5bd99383fb0",
"successURL": "https://example.ca/confirmrecovery/bb1b9928932",
"contact": "c********n@example.com"
},
"recoveryTokent":
{
"type": "recoveryToken"
},
"proofOfPossession":
{
"type": "proofOfPossession",
"alg": "RS256",
"nonce": "eET5udtV7aoX8Xl8gYiZIA",
"hints": {
"jwk": {
"kty": "RSA",
"e": "AQAB",
"n": "KxITJ0rNlfDMAtfDr8eAw...fSSoehDFNZKQKzTZPtQ"
},
"certFingerprints": [
"93416768eb85e33adc4277f4c9acd63e7418fcfe",
"16d95b7b63f1972b980b14c20291f3c0d1855d95",
"48b46570d9fc6358108af43ad1649484def0debf"
],
"subjectKeyIdentifiers":
["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"],
"serialNumbers": [34234239832, 23993939911, 17],
"issuers": [
"C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA",
"O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure"
],
"authorizedFor": ["www.example.com", "example.net"]
}
}
}
def get_dv_challenges():
"""Returns all auth challenges."""
return [chall for typ, chall in CHALLENGES.iteritems()
if typ in CONFIG.DV_CHALLENGES]
def get_client_challenges():
"""Returns all client challenges."""
return [chall for typ, chall in CHALLENGES.iteritems()
if typ in CONFIG.CLIENT_CHALLENGES]
def get_challenges():
"""Returns all challenges."""
return [chall for chall in CHALLENGES.itervalues()]
def gen_combos(challs):
"""Generate natural combinations for challs."""
dv_chall = []
renewal_chall = []
combos = []
for i, chall in enumerate(challs):
if chall["type"] in CONFIG.DV_CHALLENGES:
dv_chall.append(i)
else:
renewal_chall.append(i)
# Gen combos for 1 of each type
for i in range(len(dv_chall)):
for j in range(len(renewal_chall)):
combos.append([i, j])
return combos
def get_chall_msg(iden, nonce, challenges, combos=None):
"""Produce an ACME challenge message."""
chall_msg = {
"type": "challenge",
"sessionID": iden,
"nonce": nonce,
"challenges": challenges
}
if combos is None:
return chall_msg
chall_msg["combinations"] = combos
return chall_msg
@@ -0,0 +1 @@
"""Let's Encrypt Apache Tests"""
@@ -17,7 +17,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"):
work_dir = tempfile.mkdtemp("work")
test_configs = pkg_resources.resource_filename(
__name__, "testdata/%s" % test_dir)
"letsencrypt.client.tests", "testdata/%s" % test_dir)
shutil.copytree(
test_configs, os.path.join(temp_dir, test_dir), symlinks=True)
@@ -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,21 +8,22 @@ import unittest
import mock
import zope.component
from letsencrypt.client import display
from letsencrypt.client import challenge_util
from letsencrypt.client import client
from letsencrypt.client import errors
from letsencrypt.client.apache import configurator
from letsencrypt.client.apache import obj
from letsencrypt.client.apache import parser
from letsencrypt.client.tests import config_util
from letsencrypt.client.tests.apache import config_util
class TwoVhost80Test(unittest.TestCase):
"""Test two standard well configured HTTP vhosts."""
def setUp(self):
zope.component.provideUtility(display.NcursesDisplay())
#zope.component.provideUtility(display.NcursesDisplay())
self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup(
"debian_apache_2_4/two_vhost_80")
@@ -159,5 +161,42 @@ class TwoVhost80Test(unittest.TestCase):
self.assertRaises(
errors.LetsEncryptConfiguratorError, self.config.get_version)
@mock.patch("letsencrypt.client.apache.configurator."
"dvsni.ApacheDvsni.perform")
@mock.patch("letsencrypt.client.apache.configurator."
"ApacheConfigurator.restart")
def test_perform(self, mock_restart, mock_dvsni_perform):
# 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(
"letsencrypt.client.tests", 'testdata/rsa256_key.pem')
rsa256_pem = pkg_resources.resource_string(
"letsencrypt.client.tests", 'testdata/rsa256_key.pem')
auth_key = client.Client.Key(rsa256_file, rsa256_pem)
chall1 = challenge_util.DvsniChall(
"encryption-example.demo",
"jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
"37bc5eb75d3e00a19b4f6355845e5a18",
auth_key)
chall2 = challenge_util.DvsniChall(
"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)
self.assertEqual(mock_restart.call_count, 1)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,152 @@
"""Test for letsencrypt.client.apache.dvsni."""
import os
import pkg_resources
import unittest
import shutil
import mock
import zope.component
from letsencrypt.client import challenge_util
from letsencrypt.client import client
from letsencrypt.client import CONFIG
from letsencrypt.client.tests.apache import config_util
class DvsniPerformTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.apache import dvsni
#zope.component.provideUtility(display.NcursesDisplay())
self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup(
"debian_apache_2_4/two_vhost_80")
self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir)
# Final slash is currently important
self.config_path = os.path.join(
self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/")
config = config_util.get_apache_configurator(
self.config_path, self.config_dir, self.work_dir, self.ssl_options)
self.sni = dvsni.ApacheDvsni(config)
rsa256_file = pkg_resources.resource_filename(
"letsencrypt.client.tests", 'testdata/rsa256_key.pem')
rsa256_pem = pkg_resources.resource_string(
"letsencrypt.client.tests", 'testdata/rsa256_key.pem')
auth_key = client.Client.Key(rsa256_file, rsa256_pem)
self.challs = []
self.challs.append(challenge_util.DvsniChall(
"encryption-example.demo",
"jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
"37bc5eb75d3e00a19b4f6355845e5a18",
auth_key))
self.challs.append(challenge_util.DvsniChall(
"letsencrypt.demo",
"uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
"59ed014cac95f77057b1d7a1b2c596ba",
auth_key))
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def test_perform0(self):
resp = self.sni.perform()
self.assertIs(resp, None)
@mock.patch("letsencrypt.client.apache.configurator."
"ApacheConfigurator.restart")
@mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert")
def test_perform1(self, mock_dvsni_gen_cert, mock_restart):
chall = self.challs[0]
self.sni.add_chall(chall)
mock_dvsni_gen_cert.return_value = "randomS1"
responses = self.sni.perform()
self.assertEqual(mock_dvsni_gen_cert.call_count, 1)
calls = mock_dvsni_gen_cert.call_args_list
expected_call_list = [
(self.sni.get_cert_file(chall.nonce), chall.domain,
chall.r_b64, chall.nonce, chall.key)
]
for i in range(len(expected_call_list)):
for j in range(len(expected_call_list[0])):
self.assertEqual(calls[i][0][j], expected_call_list[i][j])
self.assertEqual(
len(self.sni.config.parser.find_dir(
"Include", self.sni.challenge_conf)),
1)
self.assertEqual(len(responses), 1)
self.assertEqual(responses[0]["s"], "randomS1")
@mock.patch("letsencrypt.client.apache.configurator."
"ApacheConfigurator.restart")
@mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert")
def test_perform2(self, mock_dvsni_gen_cert, mock_restart):
for chall in self.challs:
self.sni.add_chall(chall)
mock_dvsni_gen_cert.side_effect = ["randomS0", "randomS1"]
responses = self.sni.perform()
self.assertEqual(mock_dvsni_gen_cert.call_count, 2)
calls = mock_dvsni_gen_cert.call_args_list
expected_call_list = []
for chall in self.challs:
expected_call_list.append(
(self.sni.get_cert_file(chall.nonce), chall.domain,
chall.r_b64, chall.nonce, chall.key))
for i in range(len(expected_call_list)):
for j in range(len(expected_call_list[0])):
self.assertEqual(calls[i][0][j], expected_call_list[i][j])
self.assertEqual(
len(self.sni.config.parser.find_dir(
"Include", self.sni.challenge_conf)),
1)
self.assertEqual(len(responses), 2)
for i in range(2):
self.assertEqual(responses[i]["s"], "randomS%d" % i)
def test_mod_config(self):
from letsencrypt.client.apache.obj import Addr
for chall in self.challs:
self.sni.add_chall(chall)
v_addr1 = [Addr(("1.2.3.4", "443")), Addr(("5.6.7.8", "443"))]
v_addr2 = [Addr(("127.0.0.1", "443"))]
ll_addr = []
ll_addr.append(v_addr1)
ll_addr.append(v_addr2)
self.sni._mod_config(ll_addr) # pylint: disable=protected-access
self.sni.config.save()
self.sni.config.parser.find_dir("Include", self.sni.challenge_conf)
vh_match = self.sni.config.aug.match(
"/files" + self.sni.challenge_conf + "//VirtualHost")
vhs = []
for match in vh_match:
# pylint: disable=protected-access
vhs.append(self.sni.config._create_vhost(match))
self.assertEqual(len(vhs), 2)
for vhost in vhs:
if vhost.addrs == set(v_addr1):
self.assertEqual(
vhost.names,
set([str(self.challs[0].nonce + CONFIG.INVALID_EXT)]))
else:
self.assertEqual(vhost.addrs, set(v_addr2))
self.assertEqual(
vhost.names,
set([str(self.challs[1].nonce + CONFIG.INVALID_EXT)]))
@@ -1,14 +1,13 @@
"""Test the helper objects in apache.obj.py."""
import unittest
from letsencrypt.client.apache import obj
class AddrTest(unittest.TestCase):
"""Test the Addr class."""
def setUp(self):
self.addr1 = obj.Addr.fromstring("192.168.1.1")
self.addr2 = obj.Addr.fromstring("192.168.1.1:*")
self.addr3 = obj.Addr.fromstring("192.168.1.1:80")
from letsencrypt.client.apache.obj import Addr
self.addr1 = Addr.fromstring("192.168.1.1")
self.addr2 = Addr.fromstring("192.168.1.1:*")
self.addr3 = Addr.fromstring("192.168.1.1:80")
def test_fromstring(self):
self.assertEqual(self.addr1.get_addr(), "192.168.1.1")
@@ -36,9 +35,10 @@ class AddrTest(unittest.TestCase):
self.assertNotEqual(self.addr1, 3333)
def test_set_inclusion(self):
from letsencrypt.client.apache.obj import Addr
set_a = set([self.addr1, self.addr2])
addr1b = obj.Addr.fromstring("192.168.1.1")
addr2b = obj.Addr.fromstring("192.168.1.1:*")
addr1b = Addr.fromstring("192.168.1.1")
addr2b = Addr.fromstring("192.168.1.1:*")
set_b = set([addr1b, addr2b])
self.assertEqual(set_a, set_b)
@@ -47,14 +47,18 @@ class AddrTest(unittest.TestCase):
class VirtualHostTest(unittest.TestCase):
"""Test the VirtualHost class."""
def setUp(self):
self.vhost1 = obj.VirtualHost(
from letsencrypt.client.apache.obj import VirtualHost
from letsencrypt.client.apache.obj import Addr
self.vhost1 = VirtualHost(
"filep", "vh_path",
set([obj.Addr.fromstring("localhost")]), False, False)
set([Addr.fromstring("localhost")]), False, False)
def test_eq(self):
vhost1b = obj.VirtualHost(
from letsencrypt.client.apache.obj import Addr
from letsencrypt.client.apache.obj import VirtualHost
vhost1b = VirtualHost(
"filep", "vh_path",
set([obj.Addr.fromstring("localhost")]), False, False)
set([Addr.fromstring("localhost")]), False, False)
self.assertEqual(vhost1b, self.vhost1)
self.assertEqual(str(vhost1b), str(self.vhost1))
@@ -1,3 +1,4 @@
"""Tests the ApacheParser class."""
import os
import shutil
import sys
@@ -10,11 +11,11 @@ import zope.component
from letsencrypt.client import display
from letsencrypt.client import errors
from letsencrypt.client.apache import parser
from letsencrypt.client.tests import config_util
from letsencrypt.client.tests.apache import config_util
class ApacheParserTest(unittest.TestCase):
"""Apache Parser Test."""
def setUp(self):
zope.component.provideUtility(display.FileDisplay(sys.stdout))
@@ -0,0 +1,425 @@
"""Test auth_handler.py."""
import unittest
import mock
from letsencrypt.client.tests import acme_util
TRANSLATE = {"dvsni": "DvsniChall",
"simpleHttps": "SimpleHttpsChall",
"dns": "DnsChall",
"recoveryToken": "RecTokenChall",
"recoveryContact": "RecContactChall",
"proofOfPossession": "PopChall"}
# pylint: disable=protected-access
class SatisfyChallengesTest(unittest.TestCase):
"""verify_identities test."""
def setUp(self):
from letsencrypt.client.auth_handler import AuthHandler
self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator')
self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator')
self.mock_dv_auth.get_chall_pref.return_value = ["dvsni"]
self.mock_client_auth.get_chall_pref.return_value = ["recoveryToken"]
self.mock_client_auth.perform.side_effect = gen_auth_resp
self.mock_dv_auth.perform.side_effect = gen_auth_resp
self.handler = AuthHandler(
self.mock_dv_auth, self.mock_client_auth, None)
def test_name1_dvsni1(self):
dom = "0"
challenge = [acme_util.CHALLENGES["dvsni"]]
msg = acme_util.get_chall_msg(dom, "nonce0", challenge)
self.handler.add_chall_msg(dom, msg, "dummy_key")
self.handler._satisfy_challenges()
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual("DvsniChall0", self.handler.responses[dom][0])
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
def test_name5_dvsni5(self):
challenge = [acme_util.CHALLENGES["dvsni"]]
for i in range(5):
self.handler.add_chall_msg(
str(i),
acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge),
"dummy_key")
self.handler._satisfy_challenges()
self.assertEqual(len(self.handler.responses), 5)
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
# Each message contains 1 auth, 0 client
for i in range(5):
dom = str(i)
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual(self.handler.responses[dom][0], "DvsniChall%d" % i)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertEqual(
type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall")
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name1_auth(self, mock_chall_path):
dom = "0"
challenges = acme_util.get_dv_challenges()
combos = acme_util.gen_combos(challenges)
self.handler.add_chall_msg(
dom,
acme_util.get_chall_msg("0", "nonce0", challenges, combos),
"dummy_key")
path = gen_path(["simpleHttps"], challenges)
mock_chall_path.return_value = path
self.handler._satisfy_challenges()
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), len(challenges))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, challenges))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertEqual(
type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall")
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name1_all(self, mock_chall_path):
dom = "0"
challenges = acme_util.get_challenges()
combos = acme_util.gen_combos(challenges)
self.handler.add_chall_msg(
dom,
acme_util.get_chall_msg(dom, "nonce0", challenges, combos),
"dummy_key")
path = gen_path(["simpleHttps", "recoveryToken"], challenges)
mock_chall_path.return_value = path
self.handler._satisfy_challenges()
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), len(challenges))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 1)
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, challenges))
self.assertEqual(
type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall")
self.assertEqual(
type(self.handler.client_c[dom][0].chall).__name__, "RecTokenChall")
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name5_all(self, mock_chall_path):
challenges = acme_util.get_challenges()
combos = acme_util.gen_combos(challenges)
for i in range(5):
self.handler.add_chall_msg(
str(i),
acme_util.get_chall_msg(
str(i), "nonce%d" % i, challenges, combos),
"dummy_key")
path = gen_path(["dvsni", "recoveryContact"], challenges)
mock_chall_path.return_value = path
self.handler._satisfy_challenges()
self.assertEqual(len(self.handler.responses), 5)
for i in range(5):
self.assertEqual(
len(self.handler.responses[str(i)]), len(challenges))
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
for i in range(5):
dom = str(i)
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, challenges))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 1)
self.assertEqual(
type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall")
self.assertEqual(
type(self.handler.client_c[dom][0].chall).__name__,
"RecContactChall")
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name5_mix(self, mock_chall_path):
paths = []
chosen_chall = [["dns"],
["dvsni"],
["simpleHttps", "proofOfPossession"],
["simpleHttps"],
["dns", "recoveryToken"]]
challenge_list = [acme_util.get_dv_challenges(),
[acme_util.CHALLENGES["dvsni"]],
acme_util.get_challenges(),
acme_util.get_dv_challenges(),
acme_util.get_challenges()]
# Combos doesn't matter since I am overriding the gen_path function
for i in range(5):
dom = str(i)
paths.append(gen_path(chosen_chall[i], challenge_list[i]))
self.handler.add_chall_msg(
dom,
acme_util.get_chall_msg(
dom, "nonce%d" % i, challenge_list[i]),
"dummy_key")
mock_chall_path.side_effect = paths
self.handler._satisfy_challenges()
self.assertEqual(len(self.handler.responses), 5)
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
for i in range(5):
dom = str(i)
resp = self._get_exp_response(i, paths[i], challenge_list[i])
self.assertEqual(self.handler.responses[dom], resp)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(
len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1)
self.assertEqual(
type(self.handler.dv_c["0"][0].chall).__name__, "DnsChall")
self.assertEqual(
type(self.handler.dv_c["1"][0].chall).__name__, "DvsniChall")
self.assertEqual(
type(self.handler.dv_c["2"][0].chall).__name__, "SimpleHttpsChall")
self.assertEqual(
type(self.handler.dv_c["3"][0].chall).__name__, "SimpleHttpsChall")
self.assertEqual(
type(self.handler.dv_c["4"][0].chall).__name__, "DnsChall")
self.assertEqual(
type(self.handler.client_c["2"][0].chall).__name__, "PopChall")
self.assertEqual(
type(self.handler.client_c["4"][0].chall).__name__, "RecTokenChall")
def _get_exp_response(self, domain, path, challenges):
exp_resp = ["null"] * len(challenges)
for i in path:
exp_resp[i] = TRANSLATE[challenges[i]["type"]] + str(domain)
return exp_resp
# pylint: disable=protected-access
class GetAuthorizationsTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.auth_handler import AuthHandler
self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator')
self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator')
self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges")
self.mock_acme_auth = mock.MagicMock(name="acme_authorization")
self.iteration = 0
self.handler = AuthHandler(
self.mock_dv_auth, self.mock_client_auth, None)
self.handler._satisfy_challenges = self.mock_sat_chall
self.handler.acme_authorization = self.mock_acme_auth
def test_solved3_at_once(self):
# Set 3 DVSNI challenges
challenge = [acme_util.CHALLENGES["dvsni"]]
for i in range(3):
self.handler.add_chall_msg(
str(i),
acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge),
"dummy_key")
self.mock_sat_chall.side_effect = self._sat_solved_at_once
self.handler.get_authorizations()
self.assertEqual(self.mock_sat_chall.call_count, 1)
self.assertEqual(self.mock_acme_auth.call_count, 3)
exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")]
self.assertEqual(
self.mock_acme_auth.call_args_list, exp_call_list)
self._test_finished()
def _sat_solved_at_once(self):
for i in range(3):
dom = str(i)
self.handler.responses[dom] = ["DvsniChall%d" % i]
self.handler.paths[dom] = [0]
# Assignment was > 80 char...
dv_c, c_c = self.handler._challenge_factory(dom, [0])
self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c
def test_progress_failure(self):
from letsencrypt.client.errors import LetsEncryptAuthHandlerError
challenges = acme_util.get_challenges()
self.handler.add_chall_msg(
"0",
acme_util.get_chall_msg("0", "nonce0", challenges),
"dummy_key")
# Don't do anything to satisfy challenges
self.mock_sat_chall.side_effect = self._sat_failure
self.assertRaises(
LetsEncryptAuthHandlerError, self.handler.get_authorizations)
# Check to make sure program didn't loop
self.assertEqual(self.mock_sat_chall.call_count, 1)
def _sat_failure(self):
dom = "0"
self.handler.paths[dom] = gen_path(
["dns", "recoveryToken"], self.handler.msgs[dom]["challenges"])
dv_c, c_c = self.handler._challenge_factory(
dom, self.handler.paths[dom])
self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c
def test_incremental_progress(self):
challs = []
challs.append(acme_util.get_challenges())
challs.append(acme_util.get_dv_challenges())
for i in range(2):
dom = str(i)
self.handler.add_chall_msg(
dom,
acme_util.get_chall_msg(dom, "nonce%d" % i, challs[i]),
"dummy_key")
self.mock_sat_chall.side_effect = self._sat_incremental
self.handler.get_authorizations()
self._test_finished()
self.assertEqual(self.mock_acme_auth.call_args_list,
[mock.call("1"), mock.call("0")])
def _sat_incremental(self):
from letsencrypt.client.errors import LetsEncryptAuthHandlerError
# Exact responses don't matter, just path/response match
if self.iteration == 0:
# Only solve one of "0" required challs
self.handler.responses["0"][1] = "onecomplete"
self.handler.responses["0"][3] = None
self.handler.responses["1"] = ["null", "null", "goodresp"]
self.handler.paths["0"] = [1, 3]
self.handler.paths["1"] = [2]
# This is probably overkill... but set it anyway
dv_c, c_c = self.handler._challenge_factory("0", [1, 3])
self.handler.dv_c["0"], self.handler.client_c["0"] = dv_c, c_c
dv_c, c_c = self.handler._challenge_factory("1", [2])
self.handler.dv_c["1"], self.handler.client_c["1"] = dv_c, c_c
self.iteration += 1
elif self.iteration == 1:
# Quick check to make sure it was actually completed.
self.assertEqual(
self.mock_acme_auth.call_args_list, [mock.call("1")])
self.handler.responses["0"][1] = "now_finish"
self.handler.responses["0"][3] = "finally!"
else:
raise LetsEncryptAuthHandlerError(
"Failed incremental test: too many invocations")
def _test_finished(self):
self.assertFalse(self.handler.msgs)
self.assertFalse(self.handler.dv_c)
self.assertFalse(self.handler.responses)
self.assertFalse(self.handler.paths)
self.assertFalse(self.handler.domains)
# pylint: disable=protected-access
class PathSatisfiedTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.auth_handler import AuthHandler
self.handler = AuthHandler(None, None, None)
def test_satisfied_true(self):
dom = ["0", "1", "2", "3", "4"]
self.handler.paths[dom[0]] = [1, 2]
self.handler.responses[dom[0]] = ["null", "sat", "sat2", "null"]
self.handler.paths[dom[1]] = [0]
self.handler.responses[dom[1]] = ["sat", None, None, "null"]
self.handler.paths[dom[2]] = [0]
self.handler.responses[dom[2]] = ["sat"]
self.handler.paths[dom[3]] = []
self.handler.responses[dom[3]] = []
self.handler.paths[dom[4]] = []
self.handler.responses[dom[4]] = ["respond... sure"]
for i in range(5):
self.assertTrue(self.handler._path_satisfied(dom[i]))
def test_not_satisfied(self):
dom = ["0", "1", "2", "3", "4"]
self.handler.paths[dom[0]] = [1, 2]
self.handler.responses[dom[0]] = ["sat1", "null", "sat2", "null"]
self.handler.paths[dom[1]] = [0]
self.handler.responses[dom[1]] = [None, "null", "null", "null"]
self.handler.paths[dom[2]] = [0]
self.handler.responses[dom[2]] = [None]
self.handler.paths[dom[3]] = [0]
self.handler.responses[dom[3]] = ["null"]
for i in range(4):
self.assertFalse(self.handler._path_satisfied(dom[i]))
def gen_auth_resp(chall_list):
return ["%s%s" % (type(chall).__name__, chall.domain)
for chall in chall_list]
def gen_path(str_list, challenges):
path = []
for i, chall in enumerate(challenges):
for str_chall in str_list:
if chall["type"] == str_chall:
path.append(i)
continue
return path
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,80 @@
import unittest
import mock
class PerformTest(unittest.TestCase):
"""Test client perform function."""
def setUp(self):
from letsencrypt.client.client_authenticator import ClientAuthenticator
self.auth = ClientAuthenticator("demo_server.org")
self.auth.rec_token.perform = mock.MagicMock(
name="rec_token_perform", side_effect=gen_client_resp)
def test_rec_token1(self):
from letsencrypt.client.challenge_util import RecTokenChall
token = RecTokenChall("0")
responses = self.auth.perform([token])
self.assertEqual(responses, ["RecTokenChall0"])
def test_rec_token5(self):
from letsencrypt.client.challenge_util import RecTokenChall
tokens = []
for i in range(5):
tokens.append(RecTokenChall(str(i)))
responses = self.auth.perform(tokens)
self.assertEqual(len(responses), 5)
for i in range(5):
self.assertEqual(responses[i], "RecTokenChall%d" % i)
def test_unexpected(self):
from letsencrypt.client.challenge_util import DvsniChall
from letsencrypt.client.errors import LetsEncryptClientAuthError
unexpected = DvsniChall("0", "rb64", "123", "invalid_key")
self.assertRaises(
LetsEncryptClientAuthError, self.auth.perform, [unexpected])
class CleanupTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.client_authenticator import ClientAuthenticator
self.auth = ClientAuthenticator("demo_server.org")
self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup")
self.auth.rec_token.cleanup = self.mock_cleanup
def test_rec_token2(self):
from letsencrypt.client.challenge_util import RecTokenChall
token1 = RecTokenChall("0")
token2 = RecTokenChall("1")
self.auth.cleanup([token1, token2])
self.assertEqual(self.mock_cleanup.call_args_list,
[mock.call(token1), mock.call(token2)])
def test_unexpected(self):
from letsencrypt.client.challenge_util import DvsniChall
from letsencrypt.client.challenge_util import RecTokenChall
from letsencrypt.client.errors import LetsEncryptClientAuthError
token = RecTokenChall("0")
unexpected = DvsniChall("0", "rb64", "123", "dummy_key")
self.assertRaises(
LetsEncryptClientAuthError, self.auth.cleanup, [token, unexpected])
def gen_client_resp(chall):
return "%s%s" % (type(chall).__name__, chall.domain)
if __name__ == '__main__':
unittest.main()
+3
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)
@@ -0,0 +1,64 @@
"""Tests for recovery_token.py."""
import os
import unittest
import shutil
import tempfile
import mock
class RecoveryTokenTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.recovery_token import RecoveryToken
server = "demo_server"
self.base_dir = tempfile.mkdtemp("tokens")
self.token_dir = os.path.join(self.base_dir, server)
self.rec_token = RecoveryToken(server, self.base_dir)
def tearDown(self):
shutil.rmtree(self.base_dir)
def test_store_token(self):
self.rec_token.store_token("example.com", 111)
path = os.path.join(self.token_dir, "example.com")
self.assertTrue(os.path.isfile(path))
with open(path) as token_fd:
self.assertEqual(token_fd.read(), "111")
def test_requires_human(self):
self.rec_token.store_token("example2.com", 222)
self.assertFalse(self.rec_token.requires_human("example2.com"))
self.assertTrue(self.rec_token.requires_human("example3.com"))
def test_cleanup(self):
from letsencrypt.client.challenge_util import RecTokenChall
self.rec_token.store_token("example3.com", 333)
self.assertFalse(self.rec_token.requires_human("example3.com"))
self.rec_token.cleanup(RecTokenChall("example3.com"))
self.assertTrue(self.rec_token.requires_human("example3.com"))
# Shouldn't throw an error
self.rec_token.cleanup(RecTokenChall("example4.com"))
def test_perform_stored(self):
from letsencrypt.client.challenge_util import RecTokenChall
self.rec_token.store_token("example4.com", 444)
response = self.rec_token.perform(RecTokenChall("example4.com"))
self.assertEqual(response, {"type": "recoveryToken", "token": "444"})
@mock.patch("letsencrypt.client.recovery_token.zope.component.getUtility")
def test_perform_not_stored(self, mock_input):
from letsencrypt.client.challenge_util import RecTokenChall
mock_input().generic_input.side_effect = [(0, "555"), (1, "000")]
response = self.rec_token.perform(RecTokenChall("example5.com"))
self.assertEqual(response, {"type": "recoveryToken", "token": "555"})
response = self.rec_token.perform(RecTokenChall("example6.com"))
self.assertIs(response, None)
if __name__ == '__main__':
unittest.main()
+2 -3
View File
@@ -122,7 +122,7 @@ def main():
acme = client.Client(server, domains, privkey, auth, installer)
# Validate the key and csr
client.validate_key_csr(privkey, csr, domains)
client.validate_key_csr(privkey, csr)
cert_file, chain_file = acme.obtain_certificate(csr)
vhost = acme.deploy_certificate(privkey, cert_file, chain_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)
+1 -1
View File
@@ -14,7 +14,7 @@ commands =
[testenv:cover]
commands =
python setup.py dev
python setup.py nosetests --with-coverage --cover-min-percentage=47
python setup.py nosetests --with-coverage --cover-min-percentage=59
[testenv:lint]
commands =