1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-26 07:41:33 +03:00

Initial ACME compliant DVSNI commit

This commit is contained in:
James Kasten
2014-11-06 05:37:22 -05:00
parent f3b0ab0db7
commit 80799e28a0
5 changed files with 215 additions and 136 deletions

View File

@@ -33,5 +33,8 @@ difficulty = 23
cert_file = CERT_DIR + "trustify-cert.pem"
chain_file = CERT_DIR + "trustify-chain.pem"
#Invalid Extension
INVALID_EXT = ".acme.invalid"
# Rewrite rule arguments used for redirections to https vhost
REWRITE_HTTPS_ARGS = ["^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"]

26
trustify/client/client.py Normal file → Executable file
View File

@@ -327,7 +327,7 @@ class Client(object):
sys.exit(1)
if not self.csr_file:
csr_pem = self.make_csr(self.names)
csr_pem = trustify_util.make_csr(self.key_file, self.names)
# Save CSR
trustify_util.make_or_verify_dir(CERT_DIR, 0755)
csr_f, self.csr_file = trustify_util.unique_file(CERT_DIR + "csr-trustify.pem", 0644)
@@ -356,30 +356,6 @@ class Client(object):
return key_pem
def make_csr(self, domains):
"""
Returns new CSR in PEM form using self.key_file containing all domains
"""
assert domains, "Must provide one or more hostnames for the CSR."
rsa_key = M2Crypto.RSA.load_key(self.key_file)
pk = EVP.PKey()
pk.assign_rsa(rsa_key)
x = X509.Request()
x.set_pubkey(pk)
name = x.get_subject()
name.CN = domains[0]
extstack = X509.X509_Extension_Stack()
for d in domains:
ext = X509.new_extension('subjectAltName', 'DNS:%s' % d)
extstack.push(ext)
x.add_extensions(extstack)
x.sign(pk,'sha1')
assert x.verify(pk)
pk2 = x.get_pubkey()
assert x.verify(pk2)
return x.as_pem()
def __rsa_sign(self, key, data):
"""
Sign this data with this private key. For client-side use.

View File

@@ -10,11 +10,12 @@ import shutil
import errno
from trustify.client.CONFIG import SERVER_ROOT, BACKUP_DIR
#from CONFIG import SERVER_ROOT, BACKUP_DIR, MODIFIED_FILES, REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR
from trustify.client.CONFIG import REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR
from trustify.client.CONFIG import TEMP_CHECKPOINT_DIR, IN_PROGRESS_DIR
from trustify.client.CONFIG import OPTIONS_SSL_CONF
from trustify.client import logger, trustify_util
#import logger
#from CONFIG import SERVER_ROOT, BACKUP_DIR, REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR, TEMP_CHECKPOINT_DIR, IN_PROGRESS_DIR, OPTIONS_SSL_CONF
#import logger, trustify_util
# Question: Am I missing any attacks that can result from modifying CONFIG file?
# Configurator should be turned into a Singleton
@@ -66,6 +67,8 @@ class Configurator(object):
# relevant files - I believe -> NO_MODL_AUTOLOAD
# TODO: Use server_root instead SERVER_ROOT
self.server_root = server_root
# Set Augeas flags to save backup
self.aug = augeas.Augeas(flags=augeas.Augeas.NONE)
@@ -78,6 +81,9 @@ class Configurator(object):
self.check_parsing_errors()
# This problem has been fixed in Augeas 1.0
self.standardize_excl()
# Determine user's main config file
self.__set_user_config_file()
self.save_notes = ""
@@ -88,9 +94,9 @@ class Configurator(object):
self.verify_setup()
# Note: initialization doesn't check to see if the config is correct
# by Apache's standards. This should be done by the client if it is
# desired. There may be instances where correct configuration isn't
# required on startup.
# 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.
# TODO: This function can be improved to ensure that the final directives
# are being modified whether that be in the include files or in the
@@ -217,8 +223,19 @@ class Configurator(object):
return all_names
def __is_private_ip(ipaddr):
re.compile()
def __set_user_config_file(self, filename = ''):
if filename:
self.user_config_file = filename
else:
# Basic check to see if httpd.conf exists and is included via direct include
# httpd.conf was very common as a user file in Apache 2.2
if os.path.isfile(self.server_root + 'httpd.conf') and self.find_directive(self.case_i("Include"), self.case_i("httpd.conf")):
self.user_config_file = self.server_root + 'httpd.conf'
else:
self.user_config_file = self.server_root + 'apache2.conf'
#def __is_private_ip(ipaddr):
# re.compile()
def __add_servernames(self, host):
@@ -293,7 +310,7 @@ class Configurator(object):
aug_file_path = "/files%sports.conf" % SERVER_ROOT
self.add_dir_to_ifmodssl(aug_file_path, "NameVirtualHost", addr)
if len(self.find_directive(self.case_i("NameVirtualHost"), addr)) == 0:
if len(self.find_directive(self.case_i("NameVirtualHost"), self.case_i(addr))) == 0:
logger.warn("ports.conf is not included in your Apache config...")
logger.warn("Adding NameVirtualHost directive to httpd.conf")
self.add_dir_to_ifmodssl("/files" + SERVER_ROOT + "httpd.conf", "NameVirtualHost", addr)
@@ -390,6 +407,10 @@ class Configurator(object):
transformation by calling case_i() on everything to maintain
compatibility.
"""
#Debug code
#print "find_dir:", directive, "arg:", arg, " | Looking in:", start
# No regexp code
# if arg is None:
# matches = self.aug.match(start + "//*[self::directive='"+directive+"']/arg")
# else:
@@ -413,11 +434,15 @@ class Configurator(object):
def case_i(self, string):
"""
Returns a sloppy, but necessary version of a case insensitive regex.
Any string should be able to be submitted and the string is
escaped and then made case insensitive.
May be replaced by a more proper /i once augeas 1.0 is widely
supported.
"""
return '[' + "][".join([c.upper()+c.lower() for c in string]) + ']'
#return '[' + "][".join([c.upper()+c.lower() if c.isalpha() else c for c in re.escape(string)]) + ']'
return "".join(["["+c.upper()+c.lower()+"]" if c.isalpha() else c for c in re.escape(string)])
def strip_dir(self, path):
"""
Precondition: file_path is a file path, ie. not an augeas section
@@ -550,7 +575,7 @@ class Configurator(object):
self.add_dir(vh_p[0], "SSLCertificateFile", "/etc/ssl/certs/ssl-cert-snakeoil.pem")
self.add_dir(vh_p[0], "SSLCertificateKeyFile", "/etc/ssl/private/ssl-cert-snakeoil.key")
self.add_dir(vh_p[0], "Include", CONFIG_DIR + "options-ssl.conf")
self.add_dir(vh_p[0], "Include", OPTIONS_SSL_CONF)
# Log actions and create save notes
logger.info("Created an SSL vhost at %s" % ssl_fp)
@@ -927,7 +952,12 @@ LogLevel warn \n\
with open(file_list, 'r') as f:
filepaths = f.read().splitlines()
for fp in filepaths:
os.remove(fp)
# Files are registered before they are added... so check to see if file
# exists first
if os.path.isfile(fp):
os.remove(fp)
else:
logger.warn("File: %s - Could not be found to be deleted\nProgram was probably shut down unexpectedly, in which case this is not a problem" % fp)
except IOError:
logger.fatal("Unable to remove filepaths contained within %s" % file_list)
sys.exit(41)
@@ -975,11 +1005,14 @@ LogLevel warn \n\
for e in error_files:
# Check to see if it was an error resulting from the use of
# the httpd lens
if 'httpd.aug' in self.aug.get(e + '/lens'):
lens_path = self.aug.get(e + '/lens')
# As aug.get may return null
if lens_path and 'httpd.aug' in lens_path:
# Strip off /augeas/files and /error
logger.error('There has been an error in parsing the file: %s' % e[13:len(e) - 6])
logger.error(self.aug.get(e + '/message'))
def revert_challenge_config(self):
"""
This function should reload the users original configuration files
@@ -1201,15 +1234,17 @@ LogLevel warn \n\
returns: 0 success, 1 Unable to revert, -1 Unable to delete
"""
try:
with open(cp_dir + "/FILEPATHS") as f:
filepaths = f.read().splitlines()
for idx, fp in enumerate(filepaths):
shutil.copy2(cp_dir + '/' + os.path.basename(fp) + '_' + str(idx), fp)
except:
# This file is required in all checkpoints.
logger.error("Unable to recover files from %s" % cp_dir)
return 1
if os.path.isfile(cp_dir + "/FILEPATHS"):
try:
with open(cp_dir + "/FILEPATHS") as f:
filepaths = f.read().splitlines()
for idx, fp in enumerate(filepaths):
shutil.copy2(cp_dir + '/' + os.path.basename(fp) + '_' + str(idx), fp)
except:
# This file is required in all checkpoints.
logger.error("Unable to recover files from %s" % cp_dir)
return 1
# Remove any newly added files if they exist
self.__remove_contained_files(cp_dir + "/NEW_FILES")
@@ -1315,12 +1350,15 @@ def main():
config = Configurator()
logger.setLogger(logger.FileLogger(sys.stdout))
logger.setLogLevel(logger.DEBUG)
"""
for v in config.vhosts:
print v.file
print v.addrs
for name in v.names:
print name
"""
print config.find_directive(config.case_i("NameVirtualHost"), config.case_i("holla:443"))
"""
for m in config.find_directive("Listen", "443"):
print "Directive Path:", m, "Value:", config.aug.get(m)

170
trustify/client/sni_challenge.py Normal file → Executable file
View File

@@ -10,32 +10,39 @@ from os import remove, close, path
import sys
import binascii
import augeas
import jose
from trustify.client import configurator
from trustify.client.CONFIG import CONFIG_DIR, WORK_DIR, SERVER_ROOT
from trustify.client.CONFIG import CHOC_CERT_CONF, OPTIONS_SSL_CONF, APACHE_CHALLENGE_CONF
from trustify.client.CONFIG import CHOC_CERT_CONF, OPTIONS_SSL_CONF, APACHE_CHALLENGE_CONF, INVALID_EXT
from trustify.client.CONFIG import S_SIZE, NONCE_SIZE
from trustify.client import logger
from trustify.client import logger, trustify_util
from trustify.client.challenge import Challenge
# import configurator
# from CONFIG import CONFIG_DIR, WORK_DIR, SERVER_ROOT
# from CONFIG import CHOC_CERT_CONF, OPTIONS_SSL_CONF, APACHE_CHALLENGE_CONF, INVALID_EXT
# from CONFIG import S_SIZE, NONCE_SIZE
# import logger, trustify_util
# from challenge import Challenge
class SNI_Challenge(Challenge):
def __init__(self, sni_todos, req_filepath, key_filepath, config):
def __init__(self, sni_todos, key_filepath, config):
'''
sni_todos: List of tuples with form (addr, y, nonce, ext_oid)
addr (string), y (byte array), nonce (hex string),
ext_oid (string)
csr: string - File path to chocolate csr
sni_todos: List of tuples with form (addr, r, nonce)
addr (string), r (base64 string), nonce (hex string)
key: string - File path to key
configurator: Configurator obj
'''
self.listSNITuple = sni_todos
self.csr = req_filepath
self.key = key_filepath
self.configurator = config
def getChocCertFile(self, nonce):
def getDvsniCertFile(self, nonce):
"""
Returns standardized name for challenge certificate
@@ -46,17 +53,6 @@ class SNI_Challenge(Challenge):
return WORK_DIR + nonce + ".crt"
def findApacheConfigFile(self):
"""
Locates the file path to the user's main apache config
result: returns file path if present
"""
if path.isfile(SERVER_ROOT + "httpd.conf"):
return SERVER_ROOT + "httpd.conf"
logger.error("Unable to find httpd.conf, file does not exist in Apache ServerRoot")
return None
def __getConfigText(self, nonce, ip_addrs, key):
"""
Chocolate virtual server configuration text
@@ -68,14 +64,14 @@ class SNI_Challenge(Challenge):
result: returns virtual host configuration text
"""
configText = "<VirtualHost " + " ".join(ip_addrs) + "> \n \
ServerName " + nonce + ".chocolate \n \
ServerName " + nonce + INVALID_EXT + " \n \
UseCanonicalName on \n \
SSLStrictSNIVHostCheck on \n \
\n \
LimitRequestBody 1048576 \n \
\n \
Include " + OPTIONS_SSL_CONF + " \n \
SSLCertificateFile " + self.getChocCertFile(nonce) + " \n \
SSLCertificateFile " + self.getDvsniCertFile(nonce) + " \n \
SSLCertificateKeyFile " + key + " \n \
\n \
DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
@@ -109,7 +105,7 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
def checkForApacheConfInclude(self, mainConfig):
"""
Adds chocolate challenge include file if it does not already exist
Adds DVSNI challenge include file if it does not already exist
within mainConfig
mainConfig: string - file path to main user apache config file
@@ -120,40 +116,59 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
#print "Including challenge virtual host(s)"
self.configurator.add_dir("/files" + mainConfig, "Include", APACHE_CHALLENGE_CONF)
def createChallengeCert(self, oid, ext, nonce, csr, key):
def createChallengeCert(self, name, ext, nonce, key):
"""
Modifies challenge certificate configuration and calls openssl binary to create a certificate
oid: string
ext: string - hex z value
nonce: string - hex
csr: string - file path to csr
key: string - file path to key
result: certificate created at getChocCertFile(nonce)
"""
self.createCHOC_CERT_CONF(name, ext)
self.updateCertConf(oid, ext)
self.configurator.register_file_creation(True, self.getChocCertFile(nonce))
subprocess.call(["openssl", "x509", "-req", "-days", "21", "-extfile", CHOC_CERT_CONF, "-extensions", "v3_ca", "-signkey", key, "-out", self.getChocCertFile(nonce), "-in", csr], stdout=open("/dev/null", 'w'), stderr=open("/dev/null", 'w'))
self.configurator.register_file_creation(True, self.getDvsniCertFile(nonce))
cert_pem = trustify_util.make_ss_cert(key, [nonce + INVALID_EXT, name, ext])
with open(self.getDvsniCertFile(nonce), 'w') as f:
f.write(cert_pem)
#print ["openssl", "x509", "-req", "-days", "21", "-extfile", CHOC_CERT_CONF, "-extensions", "v3_ca", "-signkey", key, "-out", self.getDvsniCertFile(nonce), "-in", csr]
#subprocess.call(["openssl", "x509", "-req", "-days", "21", "-extfile", CHOC_CERT_CONF, "-extensions", "v3_ca", "-signkey", key, "-out", self.getDvsniCertFile(nonce), "-in", csr], stdout=open("/dev/null", 'w'), stderr=open("/dev/null", 'w'))
def generateExtension(self, key, y):
def createCHOC_CERT_CONF(self, name, ext):
"""
Generates an OpenSSL certificate configuration file
"""
text = " # OpenSSL configuration file. \n\n \
[ v3_ca ] \n \
basicConstraints = CA:TRUE\n\
subjectAltName = @alt_names\n\n\
[ alt_names ]\n"
with open(CHOC_CERT_CONF, 'w') as f:
f.write(text)
f.write("DNS:1 = %s\n" % name)
f.write("DNS:2 = %s\n" % ext)
def generateExtension(self, r, s):
"""
Generates z to be placed in certificate extension
key: string - File path to key
y: byte array
r: byte array
s: byte array
result: returns z value
result: returns z + INVALID_EXT
"""
rsaPrivKey = M2Crypto.RSA.load_key(key)
r = rsaPrivKey.private_decrypt(y, M2Crypto.RSA.pkcs1_oaep_padding)
s = Random.get_random_bytes(S_SIZE)
extHMAC = hmac.new(r, str(s), hashlib.sha256)
return self.byteToHex(s) + extHMAC.hexdigest()
h = hashlib.new('sha256')
h.update(r)
h.update(s)
return h.hexdigest() + INVALID_EXT
def byteToHex(self, byteStr):
"""
@@ -166,33 +181,6 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
return ''.join(["%02X" % ord(x) for x in byteStr]).strip()
#Searches for the first extension specified in binary
def updateCertConf(self, oid, value):
"""
Updates the sni_challenge openssl certificate config file
oid: string - ex. 1.3.3.7
value string hex - value of OID
result: updated certificate config file
"""
confOld = open(CHOC_CERT_CONF)
confNew = open(CHOC_CERT_CONF + ".tmp", 'w')
flag = False
for line in confOld:
if "=critical, DER:" in line:
confNew.write(oid + "=critical, DER:" + value + "\n")
flag = True
else:
confNew.write(line)
if flag is False:
print "Error: Could not find extension in CHOC_CERT_CONF"
exit()
confNew.close()
confOld.close()
remove(CHOC_CERT_CONF)
move(CHOC_CERT_CONF + ".tmp", CHOC_CERT_CONF)
def cleanup(self):
"""
@@ -212,10 +200,8 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
"""
Sets up and reloads Apache server to handle SNI challenges
listSNITuple: List of tuples with form (addr, y, nonce, ext_oid)
addr (string), y (byte array), nonce (hex string),
ext_oid (string)
csr: string - File path to chocolate csr
listSNITuple: List of tuples with form (addr, r, nonce)
addr (string), r (base64 string), nonce (hex string)
key: string - File path to key
configurator: Configurator obj
"""
@@ -231,10 +217,10 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
print "No vhost exists with servername or alias of:", tup[0]
print "No _default_:443 vhost exists"
print "Please specify servernames in the Apache config"
return False
return None
if not self.configurator.make_server_sni_ready(vhost, default_addr):
return False
return None
for a in vhost.addrs:
if "_default_" in a:
@@ -243,56 +229,64 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
else:
addresses.append(vhost.addrs)
# Generate S
s = Random.get_random_bytes(S_SIZE)
# Create all of the challenge certs
for tup in self.listSNITuple:
ext = self.generateExtension(self.key, tup[1])
self.createChallengeCert(tup[3], ext, tup[2], self.csr, self.key)
# Need to decode from base64
r = jose.b64decode_url(tup[1])
ext = self.generateExtension(r, s)
self.createChallengeCert(tup[0], ext, tup[2], self.key)
self.modifyApacheConfig(self.findApacheConfigFile(), addresses)
self.modifyApacheConfig(self.configurator.user_config_file, addresses)
# Save reversible changes and restart the server
self.configurator.save("SNI Challenge", True)
self.configurator.restart(quiet)
return True
return jose.b64encode_url(s)
# This main function is just used for testing
def main():
key = path.abspath("key.pem")
csr = path.abspath("req.pem")
logger.setLogger(sys.stdout)
key = path.abspath("/home/ubuntu/key.pem")
csr = path.abspath("/home/ubuntu/req.pem")
logger.setLogger(logger.FileLogger(sys.stdout))
logger.setLogLevel(logger.INFO)
testkey = M2Crypto.RSA.load_key(key)
r = Random.get_random_bytes(S_SIZE)
#r = Random.get_random_bytes(S_SIZE)
r = "testValueForR"
nonce = Random.get_random_bytes(NONCE_SIZE)
#nonce = Random.get_random_bytes(NONCE_SIZE)
nonce = "nonce"
r2 = "testValueForR2"
nonce2 = "nonce2"
r = jose.b64encode_url(r)
r2 = jose.b64encode_url(r2)
#ans = dns.resolver.query("google.com")
#print ans.rrset
#return
#the second parameter is ignored
#https://www.dlitz.net/software/pycrypto/api/current/
y = testkey.public_encrypt(r, M2Crypto.RSA.pkcs1_oaep_padding)
y2 = testkey.public_encrypt(r2, M2Crypto.RSA.pkcs1_oaep_padding)
#y = testkey.public_encrypt(r, M2Crypto.RSA.pkcs1_oaep_padding)
#y2 = testkey.public_encrypt(r2, M2Crypto.RSA.pkcs1_oaep_padding)
nonce = binascii.hexlify(nonce)
nonce2 = binascii.hexlify(nonce2)
config = configurator.Configurator()
challenges = [("example.com", y, nonce, "1.3.3.7"), ("www.example.com",y2, nonce2, "1.3.3.7")]
challenges = [("client.theobroma.info", r, nonce), ("foo.theobroma.info",r2, nonce2)]
#challenges = [("127.0.0.1", y, nonce, "1.3.3.7"), ("localhost", y2, nonce2, "1.3.3.7")]
sni_chall = SNI_Challenge(challenges, csr, key, config)
sni_chall = SNI_Challenge(challenges, key, config)
if sni_chall.perform():
# Waste some time without importing time module... just for testing
for i in range(0, 12000):
if i % 2000 == 0:
print "Waiting:", i
print "Cleaning up"
sni_chall.cleanup()
#print "Cleaning up"
#sni_chall.cleanup()
else:
print "Failed SNI challenge..."

View File

@@ -2,8 +2,76 @@
import errno
import stat
import os, pwd, grp
import M2Crypto
import time
from M2Crypto import EVP, X509, RSA, ASN1
from trustify.client import logger
#import logger
def make_csr(key_file, domains):
"""
Returns new CSR in PEM form using key_file containing all domains
"""
assert domains, "Must provide one or more hostnames for the CSR."
rsa_key = M2Crypto.RSA.load_key(key_file)
pk = EVP.PKey()
pk.assign_rsa(rsa_key)
x = X509.Request()
x.set_pubkey(pk)
name = x.get_subject()
name.CN = domains[0]
extstack = X509.X509_Extension_Stack()
for d in domains:
ext = X509.new_extension('subjectAltName', 'DNS:%s' % d)
extstack.push(ext)
x.add_extensions(extstack)
x.sign(pk,'sha256')
assert x.verify(pk)
pk2 = x.get_pubkey()
assert x.verify(pk2)
return x.as_pem()
def make_ss_cert(key_file, domains):
"""
Returns new self-signed cert in PEM form using key_file containing all domains
"""
assert domains, "Must provide one or more hostnames for the CSR."
rsa_key = M2Crypto.RSA.load_key(key_file)
pk = EVP.PKey()
pk.assign_rsa(rsa_key)
x = X509.X509()
x.set_pubkey(pk)
x.set_serial_number(1337)
x.set_version(2)
t = long(time.time())
current = ASN1.ASN1_UTCTIME()
current.set_time(t)
expire = ASN1.ASN1_UTCTIME()
expire.set_time((7 * 24 * 60 * 60) + t)
x.set_not_before(current)
x.set_not_after(expire)
name = x.get_subject()
name.C = "US"
name.ST = "Michigan"
name.L = "Ann Arbor"
name.O = "University of Michigan"
name.OU = "Halderman's Research Group"
name.CN = domains[0]
x.set_issuer(x.get_subject())
x.add_ext(X509.new_extension('subjectAltName', ",".join(["DNS:%s" % d for d in domains])))
x.sign(pk, 'sha1')
assert x.verify(pk)
pk2 = x.get_pubkey()
assert x.verify(pk2)
return x.as_pem()
def make_or_verify_dir(directory, permissions=0755, uid=0):
try:
os.makedirs(directory, permissions)