Finished refactoring client.py and also reduced column size to 80 through display and client

This commit is contained in:
James Kasten
2014-11-11 01:42:46 -08:00
parent 66c37a2d40
commit 4af311894b
3 changed files with 211 additions and 132 deletions
+208 -129
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python
import M2Crypto
# It is OK to use the upstream M2Crypto here instead of our modified
# version.
import urllib2, json
# XXX TODO: per https://docs.google.com/document/pub?id=1roBIeSJsYq3Ntpf6N0PIeeAAvu4ddn7mGo6Qb7aL7ew, urllib2 is unsafe (!) and must be replaced
# XXX TODO: per https://docs.google.com/document/pub?
#id=1roBIeSJsYq3Ntpf6N0PIeeAAvu4ddn7mGo6Qb7aL7ew
# urllib2 is unsafe (!) and must be replaced
import os, grp, pwd, sys, time, random, sys, shutil
import jose, csv
import subprocess
@@ -20,8 +20,9 @@ from trustify.client.payment_challenge import Payment_Challenge
from trustify.client import configurator
from trustify.client import logger, display
from trustify.client import trustify_util, crypto_util, display
from trustify.client.CONFIG import NONCE_SIZE, RSA_KEY_SIZE, CERT_PATH, CHAIN_PATH
from trustify.client.CONFIG import SERVER_ROOT, KEY_DIR, CERT_DIR, CERT_KEY_BACKUP
from trustify.client.CONFIG import NONCE_SIZE, RSA_KEY_SIZE, CERT_PATH
from trustify.client.CONFIG import CHAIN_PATH, SERVER_ROOT, KEY_DIR, CERT_DIR
from trustify.client.CONFIG import CERT_KEY_BACKUP
from trustify.client.CONFIG import CHALLENGE_PREFERENCES, EXCLUSIVE_CHALLENGES
# it's weird to point to chocolate servers via raw IPv6 addresses, and such
# addresses can be %SCARY in some contexts, so out of paranoia let's disable
@@ -31,8 +32,9 @@ allow_raw_ipv6_server = False
class Client(object):
# In case of import, dialog needs scope over the class
dialog = None
def __init__(self, ca_server, domains=[], cert_signing_request=None, private_key=None, use_curses=True):
def __init__(self, ca_server, domains=[], cert_signing_request=None,
private_key=None, use_curses=True):
global dialog
self.curses = use_curses
@@ -44,7 +46,7 @@ class Client(object):
if domains:
self.names = domains
else:
# This function adds all names
# This function adds all names
# found within the config to self.names
self.get_all_names()
self.csr_file = cert_signing_request
@@ -54,15 +56,14 @@ class Client(object):
# TODO: Make sure key was actually used in CSR
# TODO: Make sure key has proper permissions
if self.csr_file and not self.key_file:
logger.fatal("Please provide the private key file used in generating the provided CSR")
logger.fatal("Please provide the private key file used in \
generating the provided CSR")
sys.exit(1)
self.sanity_check_names([ca_server] + domains)
self.server_url = "https://%s/acme/" % self.server
def authenticate(self):
# Check configuration
if not self.config.configtest():
@@ -88,33 +89,24 @@ class Client(object):
key_pem, csr_der = self.get_key_csr_pem()
challenge_dict = self.send(self.challenge_request(self.names))
challenge_dict = self.is_expected_msg(challenge_dict, "challenge")
#Request Challenges
challenge_dict = self.handle_challenge()
#Perform Challenges
responses, challenge_objs = self.verify_identity(challenge_dict)
# Get Authorization
self.handle_authorization(challenge_dict, challenge_objs, responses)
# Retrieve certificate
certificate_dict = self.handle_certificate(csr_der)
# Find set of virtual hosts to deploy certificates to
vhost = self.get_virtual_hosts(self.names)
authorization_dict = self.send(self.authorization_request(challenge_dict["sessionID"], self.names[0], challenge_dict["nonce"], responses))
authorization_dict = self.is_expected_msg(authorization_dict, "authorization")
if not authorization_dict:
self.cleanup_challenges(challenge_objs)
logger.fatal("Failed Authorization procedure - cleaning up challenges")
sys.exit(1)
certificate_dict = self.send(self.certificate_request(csr_der, self.key_file))
certificate_dict = self.is_expected_msg(certificate_dict, "certificate")
# Install Certificate
self.cleanup_challenges(challenge_objs)
self.install_certificate(certificate_dict, vhost)
# Perform optimal config changes
self.optimize_config(vhost)
@@ -125,16 +117,53 @@ class Client(object):
return
def handle_challenge(self):
challenge_dict = self.send(self.challenge_request(self.names))
try:
return self.is_expected_msg(challenge_dict, "challenge")
except:
logger.fatal("Unexpected error")
sys.exit(1)
def handle_authorization(self, challenge_dict, chal_objs, responses):
auth_dict = self.send(self.authorization_request(
challenge_dict["sessionID"], self.names[0],
challenge_dict["nonce"], responses))
try:
return self.is_expected_msg(auth_dict, "authorization")
except:
logger.fatal("Failed Authorization procedure - \
cleaning up challenges")
sys.exit(1)
finally:
self.cleanup_challenges(chal_objs)
def handle_certificate(self, csr_der):
certificate_dict = self.send(
self.certificate_request(csr_der, self.key_file))
try:
return self.is_expected_msg(certificate_dict, "certificate")
except:
logger.fatal("Encountered unexpected message")
sys.exit(1)
def revoke(self, c):
x = M2Crypto.X509.load_cert(c["backup_cert_file"])
cert_der = x.as_der()
#self.find_key_for_cert()
revocation_dict = self.send(self.revocation_request(c["backup_key_file"], cert_der))
revocation_dict = self.send(
self.revocation_request(c["backup_key_file"], cert_der))
revocation_dict = self.is_expected_msg(revocation_dict, "revocation")
dialog.generic_notification("You have successfully revoked the certificate for %s" % c["cn"])
display.generic_notification(
"You have successfully revoked the certificate for %s" % c["cn"])
self.remove_cert_key(c)
sys.exit(0)
@@ -147,45 +176,50 @@ class Client(object):
with open(list_file2, 'wb') as newfile:
csvwriter = csv.writer(newfile)
for row in csvreader:
if not (row[0] == str(c["idx"]) and row[1] == c["orig_cert_file"] and row[2] == c["orig_key_file"]):
if not (row[0] == str(c["idx"]) and
row[1] == c["orig_cert_file"] and
row[2] == c["orig_key_file"]):
csvwriter.writerow(row)
# remember that these are
shutil.copy2(list_file2, list_file)
os.remove(list_file2)
os.remove(c['backup_cert_file'])
os.remove(c['backup_key_file'])
def store_revocation_token(self, token):
return
def store_cert_key(self, encrypt = False):
list_file = CERT_KEY_BACKUP + "LIST"
trustify_util.make_or_verify_dir(CERT_KEY_BACKUP, 0700)
idx = 0
if encrypt:
logger.error("Unfortunately securely storing the certificates/keys is not yet available. Stay tuned for the next update!")
logger.error("Unfortunately securely storing the certificates/keys \
is not yet available. Stay tuned for the next update!")
return False
if os.path.isfile(list_file):
with open(list_file, 'r+b') as csvfile:
csvreader = csv.reader(csvfile)
for r in csvreader:
idx = int(r[0]) + 1
csvwriter = csv.writer(csvfile)
csvwriter.writerow([str(idx), self.cert_file, self.key_file])
else:
if os.path.isfile(list_file):
with open(list_file, 'r+b') as csvfile:
csvreader = csv.reader(csvfile)
for r in csvreader:
idx = int(r[0]) + 1
csvwriter = csv.writer(csvfile)
csvwriter.writerow([str(idx), self.cert_file, self.key_file])
with open(list_file, 'wb') as csvfile:
csvwriter = csv.writer(csvfile)
csvwriter.writerow(["0", self.cert_file, self.key_file])
else:
with open(list_file, 'wb') as csvfile:
csvwriter = csv.writer(csvfile)
csvwriter.writerow(["0", self.cert_file, self.key_file])
shutil.copy2(self.key_file, CERT_KEY_BACKUP + os.path.basename(self.key_file) + "_" + str(idx))
shutil.copy2(self.cert_file, CERT_KEY_BACKUP + os.path.basename(self.cert_file) + "_" + str(idx))
shutil.copy2(self.key_file,
CERT_KEY_BACKUP + os.path.basename(self.key_file) +
"_" + str(idx))
shutil.copy2(self.cert_file,
CERT_KEY_BACKUP + os.path.basename(self.cert_file) +
"_" + str(idx))
def list_certs_keys(self):
list_file = CERT_KEY_BACKUP + "LIST"
@@ -194,59 +228,73 @@ class Client(object):
if not os.path.isfile(CERT_KEY_BACKUP + "LIST"):
logger.info("You don't have any certificates saved from trustify")
return
with open(list_file, 'rb') as csvfile:
csvreader = csv.reader(csvfile)
for row in csvreader:
c = crypto_util.get_cert_info(row[1])
b_k = CERT_KEY_BACKUP + os.path.basename(row[2]) + "_" + row[0]
b_c = CERT_KEY_BACKUP + os.path.basename(row[1]) + "_" + row[0]
c["orig_key_file"] = row[2]
c["orig_cert_file"] = row[1]
c["backup_key_file"] = CERT_KEY_BACKUP + os.path.basename(row[2]) + "_" + row[0]
c["backup_cert_file"] = CERT_KEY_BACKUP + os.path.basename(row[1]) + "_" + row[0]
c["idx"] = int(row[0])
c["backup_key_file"] = b_k
c["backup_cert_file"] = b_c
certs.append(c)
if certs:
self.choose_certs(certs)
else:
display.generic_notification("There are not any trusted Let's Encrypt certificates for this server.")
display.generic_notification("There are not any trusted \
Let's Encrypt certificates for this server.")
def choose_certs(self, certs):
while True:
code, selection = display.display_certs(certs)
if code == display.OK:
if display.confirm_revocation(certs[int(selection)-1]):
self.revoke(c)
self.revoke(certs[int(selection)-1])
elif code == display.CANCEL:
exit(0)
elif code == display.HELP:
display.more_info_cert(certs[int(selection)-1])
def revocation_request(self, key_file, cert_der):
return {"type":"revocationRequest", "certificate":jose.b64encode_url(cert_der), "signature":crypto_util.create_sig(cert_der, key_file)}
def revocation_request(self, key_file, cert_der):
return {"type":"revocationRequest",
"certificate":jose.b64encode_url(cert_der),
"signature":crypto_util.create_sig(cert_der, key_file)}
def install_certificate(self, certificate_dict, vhost):
cert_chain_abspath = None
cert_fd, self.cert_file = trustify_util.unique_file(CERT_PATH, 644)
cert_fd.write(crypto_util.convert_b64_cert_to_pem(certificate_dict["certificate"]))
cert_fd.write(
crypto_util.b64_cert_to_pem(certificate_dict["certificate"]))
cert_fd.close()
logger.info("Server issued certificate; certificate written to %s" % self.cert_file)
logger.info("Server issued certificate; certificate written to %s" %
self.cert_file)
if certificate_dict.get("chain", None):
chain_fd, chain_fn = trustify_util.unique_file(CHAIN_PATH, 644)
for c in certificate_dict.get("chain", []):
chain_fd.write(crypto_util.convert_b64_cert_to_pem(c))
chain_fd.write(crypto_util.b64_cert_to_pem(c))
chain_fd.close()
logger.info("Cert chain written to %s" % chain_fn)
# This expects a valid chain file
cert_chain_abspath = os.path.abspath(chain_fn)
for host in vhost:
self.config.deploy_cert(host, os.path.abspath(self.cert_file), os.path.abspath(self.key_file), cert_chain_abspath)
self.config.deploy_cert(host,
os.path.abspath(self.cert_file),
os.path.abspath(self.key_file),
cert_chain_abspath)
# Enable any vhost that was issued to, but not enabled
if not host.enabled:
logger.info("Enabling Site " + host.file)
@@ -266,7 +314,9 @@ class Client(object):
def certificate_request(self, csr_der, key):
logger.info("Preparing and sending CSR..")
return {"type":"certificateRequest", "csr":jose.b64encode_url(csr_der), "signature":crypto_util.create_sig(csr_der, self.key_file)}
return {"type":"certificateRequest",
"csr":jose.b64encode_url(csr_der),
"signature":crypto_util.create_sig(csr_der, self.key_file)}
def cleanup_challenges(self, challenge_objs):
logger.info("Cleaning up challenges...")
@@ -277,9 +327,14 @@ class Client(object):
for i in range(rounds):
if msg_dict["type"] == expected:
return msg_dict
elif msg_dict["type"] == "error":
logger.error("%s: %s - More Info: %s" % (msg_dict["error"], msg_dict.get("message", ""), msg_dict.get("moreInfo", "")))
return None
logger.error("%s: %s - More Info: %s" %
(msg_dict["error"],
msg_dict.get("message", ""),
msg_dict.get("moreInfo", "")))
raise Exception(msg_dict["error"])
elif msg_dict["type"] == "defer":
logger.info("Waiting for %d seconds..." % delay)
time.sleep(delay)
@@ -290,32 +345,41 @@ class Client(object):
logger.fatal("Received: " + msg_dict)
sys.exit(33)
logger.error("Server has deferred past the max of %d seconds" % (rounds * delay))
logger.error("Server has deferred past the max of %d seconds" %
(rounds * delay))
return None
def authorization_request(self, id, name, server_nonce, responses):
auth_req = {"type":"authorizationRequest", "sessionID":id, "nonce":server_nonce}
auth_req["signature"] = crypto_util.create_sig(name + jose.b64decode_url(server_nonce), self.key_file)
auth_req = {"type":"authorizationRequest",
"sessionID":id,
"nonce":server_nonce}
auth_req["signature"] = crypto_util.create_sig(
name + jose.b64decode_url(server_nonce), self.key_file)
auth_req["responses"] = responses
return auth_req
def status_request(self, token):
return {"type":"statusRequest", "token":token}
def challenge_request(self, names):
#logger.info("Temporarily only enabling one name")
return {"type":"challengeRequest", "identifier": names[0]}
def verify_identity(self, c):
path = self.gen_challenge_path(c["challenges"], c.get("combinations", None))
path = self.gen_challenge_path(
c["challenges"], c.get("combinations", None))
logger.info("Peforming the following challenges:")
# Every indicies element is a list of integers referring to which challenges in the master list
# the challenge object satisfies
# Every indicies 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 indicies
challenge_objs, indicies = self.challenge_factory(self.names[0], c["challenges"], path)
challenge_objs, indicies = self.challenge_factory(
self.names[0], c["challenges"], path)
responses = [None] * len(c["challenges"])
@@ -328,10 +392,11 @@ class Client(object):
for index in indicies[i]:
responses[index] = c_obj.generate_response()
logger.info("Configured Apache for challenges; waiting for verification...")
logger.info("Configured Apache for challenges; \
waiting for verification...")
return responses, challenge_objs
def gen_challenge_path(self, challenges, combos):
"""
Generate a plan to get authority over the identity
@@ -343,11 +408,12 @@ class Client(object):
return self.__find_smart_path(challenges, combos)
return self.__find_dumb_path(challenges)
def __find_smart_path(self, challenges, combos):
"""
Can be called if combinations is included
Function uses a simple ranking system to choose the combo with the lowest cost
Function uses a simple ranking system to choose the combo with the
lowest cost
"""
chall_cost = {}
max_cost = 0
@@ -358,7 +424,7 @@ class Client(object):
best_combo = []
# Set above completing all of the available challenges
best_combo_cost = max_cost + 1
combo_total = 0
for combo in combos:
for c in combo:
@@ -368,7 +434,8 @@ class Client(object):
combo_total = 0
if not best_combo:
logger.fatal("Client does not support any combination of challenges to satisfy ACME server")
logger.fatal("Client does not support any combination of \
challenges to satisfy ACME server")
sys.exit(22)
return best_combo
@@ -376,15 +443,16 @@ class Client(object):
def __find_dumb_path(self, challenges):
"""
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
This function returns the best path that does not contain multiple
mutually exclusive challenges
"""
# Add logic for a crappy server
# Choose a DV
path = []
for pref_c in CHALLENGE_PREFERENCES:
for i, offered_c in enumerate(challenges):
if pref_c == offered_c["type"] and self.is_preferred(offered_c["type"], path):
if (pref_c == offered_c["type"] and
self.is_preferred(offered_c["type"], path)):
path.append((i, offered_c["type"]))
return [tup[0] for tup in path]
@@ -393,31 +461,38 @@ class Client(object):
def is_preferred(self, offered_c_type, path):
for tup in path:
for s in EXCLUSIVE_CHALLENGES:
# Second part is in case we eventually allow multiple names to be challenged
# at the same time
if (tup[1] in s and offered_c_type in s) and tup[1] != offered_c_type:
# Second part is in case we eventually allow multiple names
# to be challenges at the same time
if (tup[1] in s and offered_c_type in s and
tup[1] != offered_c_type):
return False
return True
def send(self, json_obj):
acme_object_validate(json.dumps(json_obj))
response = urllib2.urlopen(self.server_url, json.dumps(json_obj)).read()
acme_object_validate(response)
return json.loads(response)
try:
acme_object_validate(json.dumps(json_obj))
response = urllib2.urlopen(
self.server_url, json.dumps(json_obj)).read()
acme_object_validate(response)
return json.loads(response)
except:
logger.fatal("Send() failed... may have lost connection to server")
sys.exit(8)
def redirect_to_ssl(self, vhost):
for ssl_vh in vhost:
success, redirect_vhost = self.config.redirect_all_ssl(ssl_vh)
logger.info("\nRedirect vhost: " + redirect_vhost.file + " - " + str(success))
logger.info("\nRedirect vhost: " + redirect_vhost.file +
" - " + str(success))
# If successful, make sure redirect site is enabled
if success:
if not self.config.is_site_enabled(redirect_vhost.file):
self.config.enable_site(redirect_vhost)
logger.info("Enabling available site: " + redirect_vhost.file)
def get_virtual_hosts(self, domains):
vhost = set()
for name in domains:
@@ -428,8 +503,8 @@ class Client(object):
def challenge_factory(self, name, challenges, path):
sni_todo = []
# Since a single invocation of SNI challenge can satsify multiple challenges
# We must keep track of all the challenges it satisfies
# Since a single invocation of SNI challenge can satsify multiple
# challenges. We must keep track of all the challenges it satisfies
sni_satisfies = []
challenge_objs = []
@@ -438,20 +513,22 @@ class Client(object):
if challenges[c]["type"] == "dvsni":
logger.info("\tDomainValidateSNI challenge for name %s." % name)
sni_satisfies.append(c)
sni_todo.append( (str(name), str(challenges[c]["r"]), str(challenges[c]["nonce"])) )
sni_todo.append( (str(name), str(challenges[c]["r"]),
str(challenges[c]["nonce"])) )
elif challenges[c]["type"] == "recoveryToken":
logger.fatal("RecoveryToken Challenge type not currently supported")
sys.exit(82)
challenge_objs_indicies.append(c)
challenge_objs.append(RecoveryToken())
else:
logger.fatal("Challenge not currently supported")
sys.exit(82)
if sni_todo:
# SNI_Challenge can satisfy many sni challenges at once so only
# SNI_Challenge can satisfy many sni challenges at once so only
# one "challenge object" is issued for all sni_challenges
challenge_objs.append(SNI_Challenge(sni_todo, os.path.abspath(self.key_file), self.config))
challenge_objs.append(SNI_Challenge(
sni_todo, os.path.abspath(self.key_file), self.config))
challenge_obj_indicies.append(sni_satisfies)
logger.debug(sni_todo)
@@ -460,10 +537,10 @@ class Client(object):
def get_key_csr_pem(self, csr_return_format = 'der'):
"""
Returns key and CSR using provided files or generating new files if necessary.
Both will be saved in pem format on the filesystem. The CSR can
optionally be returned in DER format as the CSR cannot be loaded back into
M2Crypto.
Returns key and CSR using provided files or generating new files if
necessary. Both will be saved in pem format on the filesystem.
The CSR can optionally be returned in DER format as the CSR cannot be
loaded back into M2Crypto.
"""
key_pem = None
csr_pem = None
@@ -471,7 +548,8 @@ class Client(object):
key_pem = crypto_util.make_key(RSA_KEY_SIZE)
# Save file
trustify_util.make_or_verify_dir(KEY_DIR, 0700)
key_f, self.key_file = trustify_util.unique_file(KEY_DIR + "key-trustify.pem", 0600)
key_f, self.key_file = trustify_util.unique_file(
KEY_DIR + "key-trustify.pem", 0600)
key_f.write(key_pem)
key_f.close()
logger.info("Generating key: %s" % self.key_file)
@@ -486,7 +564,8 @@ class Client(object):
csr_pem, csr_der = crypto_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)
csr_f, self.csr_file = trustify_util.unique_file(
CERT_DIR + "csr-trustify.pem", 0644)
csr_f.write(csr_pem)
csr_f.close()
logger.info("Creating CSR: %s" % self.csr_file)
@@ -503,7 +582,7 @@ class Client(object):
return key_pem, csr_pem
def choice_of_ca(self):
choices = self.get_cas()
message = "Pick a Certificate Authority. They're all unique and special!"
@@ -536,7 +615,7 @@ class Client(object):
# random.shuffle(EV_choices)
choices = DV_choices + OV_choices + EV_choices
choices = [(l[0], l[1]) for l in choices]
except IOError as e:
logger.fatal("Unable to find .ca_offerings file")
sys.exit(1)
@@ -544,14 +623,15 @@ class Client(object):
return choices
def get_all_names(self):
self.names = self.config.get_all_names()
self.names = self.config.get_all_names()
if not self.names:
logger.fatal("No domain names were found in your apache config")
logger.fatal("Either specify which names you would like trustify to validate or add server names to your virtual hosts")
logger.fatal("Either specify which names you would like trustify \
to validate or add server names to your virtual hosts")
sys.exit(1)
def init_logger(self):
if self.curses:
logger.setLogger(logger.NcursesLogger())
@@ -562,7 +642,8 @@ class Client(object):
def sanity_check_names(self, names):
for name in names:
assert self.is_hostname_sane(name), `name` + " is an impossible hostname"
if not self.is_hostname_sane(name):
logger.fatal(`name` + " is an impossible hostname")
def is_hostname_sane(self, hostname):
"""
@@ -573,10 +654,10 @@ class Client(object):
allowed = s.ascii_letters + s.digits + "-." # hostnames & IPv4
if all([c in allowed for c in hostname]):
return True
if not allow_raw_ipv6_server: return False
# ipv6 is messy and complicated, can contain %zoneindex etc.
# ipv6 is messy and complicated, can contain %zoneindex etc.
import socket
try:
# is this a valid IPv6 address?
@@ -620,6 +701,4 @@ def renew(config):
# Wait for response, act accordingly
gen_req_from_cert()
# vim: set expandtab tabstop=4 shiftwidth=4
+1 -1
View File
@@ -12,7 +12,7 @@ from trustify.client import logger
from trustify.client.CONFIG import NONCE_SIZE, RSA_KEY_SIZE
def convert_b64_cert_to_pem(b64_der_cert):
def b64_cert_to_pem(b64_der_cert):
x = M2Crypto.X509.load_cert_der_string(jose.b64decode_url(b64_der_cert))
return x.as_pem()
+2 -2
View File
@@ -100,11 +100,11 @@ class NcursesDisplay(Display):
str(c["not_before"])[:-6])
for i, c in enumerate(certs)]
c, s = self.d.menu("Which certificate would you like to revoke?",
code, s = self.d.menu("Which certificate would you like to revoke?",
choices = menu_choices, help_button=True,
help_label="More Info", ok_label="Revoke",
width=WIDTH, height=HEIGHT)
return c, s
return code, s
def redirect_by_default(self):