Turn DVSNI into module, add more appropriate challenges/api

This commit is contained in:
James Kasten 2014-12-23 03:54:30 -08:00
parent eb99571a98
commit 05d803ddd3
5 changed files with 274 additions and 236 deletions

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
@ -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 = "<IfModule mod_ssl.c>\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 += "</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"
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

View file

@ -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 = "<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, 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 ("<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"

View file

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

View file

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

View file

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