diff --git a/trustify/client/CONFIG.py b/trustify/client/CONFIG.py index d8445886d..6e25f61eb 100644 --- a/trustify/client/CONFIG.py +++ b/trustify/client/CONFIG.py @@ -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]"] diff --git a/trustify/client/client.py b/trustify/client/client.py old mode 100644 new mode 100755 index 7d037f052..0f57a46d9 --- a/trustify/client/client.py +++ b/trustify/client/client.py @@ -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. diff --git a/trustify/client/configurator.py b/trustify/client/configurator.py index ff12ac39b..4e783aa57 100644 --- a/trustify/client/configurator.py +++ b/trustify/client/configurator.py @@ -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) diff --git a/trustify/client/sni_challenge.py b/trustify/client/sni_challenge.py old mode 100644 new mode 100755 index a727aecff..a9dea3fd7 --- a/trustify/client/sni_challenge.py +++ b/trustify/client/sni_challenge.py @@ -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 = " \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..." diff --git a/trustify/client/trustify_util.py b/trustify/client/trustify_util.py index cc9e449ba..df919c8fb 100644 --- a/trustify/client/trustify_util.py +++ b/trustify/client/trustify_util.py @@ -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)