diff --git a/letsencrypt/client/acme.py b/letsencrypt/client/acme.py index 52cbceb71..ecf5a9ac8 100644 --- a/letsencrypt/client/acme.py +++ b/letsencrypt/client/acme.py @@ -1,13 +1,16 @@ -"""Validate JSON objects as ACME protocol messages.""" +"""ACME protocol messages.""" import json import pkg_resources import jsonschema +from letsencrypt.client import crypto_util +from letsencrypt.client import le_util -SCHEMATA = { - schema: json.load(open(pkg_resources.resource_filename( - __name__, "schemata/%s.json" % schema))) for schema in [ + +SCHEMATA = dict([ + (schema, json.load(open(pkg_resources.resource_filename( + __name__, "schemata/%s.json" % schema)))) for schema in [ "authorization", "authorizationRequest", "certificate", @@ -18,32 +21,149 @@ SCHEMATA = { "error", "revocation", "revocationRequest", - "statusRequest" + "statusRequest", ] -} +]) -def acme_object_validate(j): - """Validate a JSON object against the ACME protocol using JSON Schema. +def acme_object_validate(json_string, schemata=None): + """Validate a JSON string against the ACME protocol using JSON Schema. + + :param json_string: Well-formed input JSON string. + :type json_string: str + + :param schemata: Mapping from type name to JSON Schema definition. + Useful for testing. + :type schemata: dict + + :returns: None if validation was successful. + :raises: jsonschema.ValidationError if validation was unsuccessful + ValueError if the object cannot even be parsed as valid JSON - Success will return None; failure to validate will raise a - jsonschema.ValidationError exception describing the reason that the - object could not be validated successfully, or a ValueError exception - if the object cannot even be parsed as valid JSON. """ - j = json.loads(j) - if not isinstance(j, dict): + schemata = SCHEMATA if schemata is None else schemata + json_object = json.loads(json_string) + if not isinstance(json_object, dict): raise jsonschema.ValidationError("this is not a dictionary object") - if "type" not in j: + if "type" not in json_object: raise jsonschema.ValidationError("missing type field") - if j["type"] not in SCHEMATA: - raise jsonschema.ValidationError("unknown type %s" % j["type"]) - jsonschema.validate(j, SCHEMATA[j["type"]]) + if json_object["type"] not in schemata: + raise jsonschema.ValidationError( + "unknown type %s" % json_object["type"]) + jsonschema.validate(json_object, schemata[json_object["type"]]) def pretty(json_string): """Return a pretty-printed version of any JSON string. Useful when printing out protocol messages for debugging purposes. + """ return json.dumps(json.loads(json_string), indent=4) + + +def challenge_request(names): + """Create ACME "challengeRequest message. + + TODO: Temporarily only enabling one name + + :param names: TODO + :type names: list + + :returns: ACME "challengeRequest" message. + :rtype: dict + + """ + return { + "type": "challengeRequest", + "identifier": names[0], + } + + +def authorization_request(req_id, name, server_nonce, responses, key_file): + """Create ACME "authoriazationRequest" message. + + :param req_id: TODO + :type req_id: TODO + + :param name: TODO + :type name: TODO + + :param server_nonce: TODO + :type server_nonce: TODO + + :param responses: TODO + :type response: TODO + + :param key_file: TODO + :type key_file: TODO + + :returns: ACME "authoriazationRequest" message. + :rtype: dict + + """ + return { + "type": "authorizationRequest", + "sessionID": req_id, + "nonce": server_nonce, + "responses": responses, + "signature": crypto_util.create_sig( + name + le_util.b64_url_dec(server_nonce), key_file), + } + + +def certificate_request(csr_der, key): + """Create ACME "certificateRequest" message. + + :param csr_der: TODO + :type csr_der: TODO + + :param key: TODO + :type key: TODO + + :returns: ACME "certificateRequest" message. + :rtype: dict + + """ + return { + "type": "certificateRequest", + "csr": le_util.b64_url_enc(csr_der), + "signature": crypto_util.create_sig(csr_der, key), + } + + +def revocation_request(key_file, cert_der): + """Create ACME "revocationRequest" message. + + :param key_file: Path to a file containing RSA key. Accepted formats + are the same as for `Crypto.PublicKey.RSA.importKey`. + :type key_file: str + + :param cert_der: DER encoded certificate. + :type cert_der: str + + :returns: ACME "revocationRequest" message. + :rtype: dict + + """ + return { + "type": "revocationRequest", + "certificate": le_util.b64_url_enc(cert_der), + "signature": crypto_util.create_sig(cert_der, key_file), + } + + +def status_request(token): + """Create ACME "statusRequest" message. + + :param token: Token provided in ACME "defer" message. + :type token: str + + :returns: ACME "statusRequest" message. + :rtype: dict + + """ + return { + "type": "statusRequest", + "token": token, + } diff --git a/letsencrypt/client/acme_test.py b/letsencrypt/client/acme_test.py new file mode 100644 index 000000000..e5eae6c9a --- /dev/null +++ b/letsencrypt/client/acme_test.py @@ -0,0 +1,58 @@ +"""Tests for letsencrypt.client.acme.""" +import unittest + +import jsonschema + + +class ACMEObjectValidateTest(unittest.TestCase): + """Tests for letsencrypt.client.acme.acme_object_validate.""" + + def setUp(self): + self.schemata = { + 'foo': { + 'type' : 'object', + 'properties' : { + 'price' : {'type' : 'number'}, + 'name' : {'type' : 'string'}, + }, + }, + } + + def _call(self, json_string): + from letsencrypt.client.acme import acme_object_validate + return acme_object_validate(json_string, self.schemata) + + def _test_fails(self, json_string): + self.assertRaises(jsonschema.ValidationError, self._call, json_string) + + def test_non_dictionary_fails(self): + self._test_fails('[]') + + def test_dict_without_type_fails(self): + self._test_fails('{}') + + def test_unknown_type_fails(self): + self._test_fails('{"type": "bar"}') + + def test_valid_returns_none(self): + self.assertTrue(self._call('{"type": "foo"}') is None) + + def test_invalid_fails(self): + self._test_fails('{"type": "foo", "price": "asd"}') + + +class PrettyTest(unittest.TestCase): + """Tests for letsencrypt.client.acme.pretty.""" + + def _call(self, json_string): + from letsencrypt.client.acme import pretty + return pretty(json_string) + + def test_it(self): + self.assertEqual( + self._call('{"foo": "bar", "foo2": "bar2"}'), + '{\n "foo2": "bar2", \n "foo": "bar"\n}') + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py index ce85516eb..0f44f7af9 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -1,3 +1,10 @@ +"""ACME challenge.""" +import sys + +from letsencrypt.client import CONFIG +from letsencrypt.client import logger + + class Challenge(object): def __init__(self, configurator): @@ -11,3 +18,119 @@ class Challenge(object): def cleanup(self): raise NotImplementedError() + + +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 challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client + in order to prove possession of the identifier. + :type challenges: list + + :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): + """ + Can be called if combinations is included + Function uses a simple ranking system to choose the combo with the + lowest cost + + :param challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client + in order to prove possession of the identifier. + :type challenges: list + + :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: + logger.fatal("Client does not support any combination of " + "challenges to satisfy ACME server") + sys.exit(22) + + return best_combo + + +def _find_dumb_path(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 + + :param challanges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client + in order to prove possession of the identifier. + :type challenges: list + + :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 diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 7aa53c3bb..f358070aa 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,5 +1,4 @@ import csv -import datetime import json import os import shutil @@ -8,11 +7,13 @@ import string import sys import time +import jsonschema import M2Crypto import requests from letsencrypt.client import acme from letsencrypt.client import apache_configurator +from letsencrypt.client import challenge from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import display @@ -21,26 +22,26 @@ from letsencrypt.client import le_util from letsencrypt.client import logger -# 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 -# them by default -allow_raw_ipv6_server = False +# 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 them by default +ALLOW_RAW_IPV6_SERVER = False + class Client(object): - # In case of import, dialog needs scope over the class - dialog = None + """ACME protocol client.""" def __init__(self, ca_server, cert_signing_request=None, private_key=None, use_curses=True): - global dialog self.curses = use_curses # Logger needs to be initialized before Configurator self.init_logger() - # TODO: Can probably figure out which configurator to use without - # special packaging based on system info - # Command line arg or client function to discover - self.config = apache_configurator.ApacheConfigurator(CONFIG.SERVER_ROOT) + # TODO: Can probably figure out which configurator to use + # without special packaging based on system info Command + # line arg or client function to discover + self.config = apache_configurator.ApacheConfigurator( + CONFIG.SERVER_ROOT) self.server = ca_server @@ -57,7 +58,9 @@ class Client(object): self.server_url = "https://%s/acme/" % self.server - def authenticate(self, domains = [], redirect = None, eula = False): + def authenticate(self, domains=None, redirect=None, eula=False): + domains = [] if domains is None else domains + # Check configuration if not self.config.config_test(): sys.exit(1) @@ -66,13 +69,14 @@ class Client(object): # Display preview warning if not eula: - with open('EULA') as f: - if not display.generic_yesno(f.read(), "Agree", "Cancel"): + with open('EULA') as eula_file: + if not display.generic_yesno(eula_file.read(), + "Agree", "Cancel"): sys.exit(0) # Display screen to select domains to validate if domains: - self.sanity_check_names([self.server] + domains) + sanity_check_names([self.server] + domains) self.names = domains else: # This function adds all names @@ -89,388 +93,126 @@ class Client(object): # TODO: Use correct server depending on CA #choice = self.choice_of_ca() - #Request Challenges - challenge_dict = self.handle_challenge() + # Request Challenges + challenge_msg = self.acme_challenge() # Get key and csr to perform challenges - key_pem, csr_der = self.get_key_csr_pem() + _, csr_der = self.get_key_csr_pem() - #Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_dict) + # Perform Challenges + responses, challenge_objs = self.verify_identity(challenge_msg) # Get Authorization - self.handle_authorization(challenge_dict, challenge_objs, responses) + self.acme_authorization(challenge_msg, challenge_objs, responses) # Retrieve certificate - certificate_dict = self.handle_certificate(csr_der) - + certificate_dict = self.acme_certificate(csr_der) # Find set of virtual hosts to deploy certificates to vhost = self.get_virtual_hosts(self.names) # Install Certificate - self.install_certificate(certificate_dict, vhost) + cert_file = self.install_certificate(certificate_dict, vhost) # Perform optimal config changes self.optimize_config(vhost) self.config.save("Completed Let's Encrypt Authentication") - self.store_cert_key(False) + self.store_cert_key(cert_file, False) - return + def acme_challenge(self): + """Handle ACME "challenge" phase. + :returns: ACME "challenge" message. + :rtype: dict - 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) + """ + return self.send_and_receive_expected( + acme.challenge_request(self.names), "challenge") - 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)) + def acme_authorization(self, challenge_msg, chal_objs, responses): + """Handle ACME "authorization" phase. + + :param challenge_msg: ACME "challenge" message. + :type challenge_msg: dict + + :param chal_objs: TODO + :type chal_objs: TODO + + :param responses: TODO + :type responses: TODO + + :returns: ACME "authorization" message. + :rtype: dict + + """ + auth_dict = self.send(acme.authorization_request( + challenge_msg["sessionID"], self.names[0], + challenge_msg["nonce"], responses, self.key_file)) try: return self.is_expected_msg(auth_dict, "authorization") except: - logger.fatal("Failed Authorization procedure - \ - cleaning up challenges") + logger.fatal("Failed Authorization procedure - " + "cleaning up challenges") sys.exit(1) - finally: self.cleanup_challenges(chal_objs) + def acme_certificate(self, csr_der): + """Handle ACME "certificate" phase. - def handle_certificate(self, csr_der): - certificate_dict = self.send( - self.certificate_request(csr_der, self.key_file)) + :param csr_der: TODO + :type csr_der: TODO - try: - return self.is_expected_msg(certificate_dict, "certificate") - except: - logger.fatal("Encountered unexpected message") - sys.exit(1) + :returns: ACME "certificate" message. + :rtype: dict + """ + logger.info("Preparing and sending CSR..") + return self.send_and_receive_expected( + acme.certificate_request(csr_der, self.key_file), "certificate") - def revoke(self, c): - x = M2Crypto.X509.load_cert(c["backup_cert_file"]) - cert_der = x.as_der() + def acme_revocation(self, cert): + """Handle ACME "revocation" phase. - #self.find_key_for_cert() - revocation_dict = self.send( - self.revocation_request(c["backup_key_file"], cert_der)) + :param cert: TODO + :type cert: dict - revocation_dict = self.is_expected_msg(revocation_dict, "revocation") + :returns: ACME "revocation" message. + :rtype: dict + + """ + cert_der = M2Crypto.X509.load_cert(cert["backup_cert_file"]).as_der() + + revocation = self.send_and_receive_expected( + acme.revocation_request(cert["backup_key_file"], cert_der), + "revocation") display.generic_notification( - "You have successfully revoked the certificate for %s" % c["cn"], width=70, height=9) - - self.remove_cert_key(c) + "You have successfully revoked the certificate for " + "%s" % cert["cn"], width=70, height=9) + remove_cert_key(cert) self.list_certs_keys() - def remove_cert_key(self, c): - list_file = CONFIG.CERT_KEY_BACKUP + "LIST" - list_file2 = CONFIG.CERT_KEY_BACKUP + "LIST.tmp" - with open(list_file, 'rb') as orgfile: - csvreader = csv.reader(orgfile) - 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"]): - csvwriter.writerow(row) + return revocation - shutil.copy2(list_file2, list_file) - os.remove(list_file2) - os.remove(c['backup_cert_file']) - os.remove(c['backup_key_file']) + def send(self, msg): + """Send ACME message to server. + :param msg: ACME message (JSON serializable). + :type msg: dict - def list_certs_keys(self): - list_file = CONFIG.CERT_KEY_BACKUP + "LIST" - certs = [] + :raises: TypeError if `msg` is not JSON serializable or + jsonschema.ValidationError if not valid ACME message or + Exception if response from server is not valid ACME message - if not os.path.isfile(CONFIG.CERT_KEY_BACKUP + "LIST"): - logger.info("You don't have any certificates saved from letsencrypt") - return + :returns: Server response message. + :rtype: dict - c_sha1_vh = {} - for x in self.config.get_all_certs_keys(): - try: - c_sha1_vh[M2Crypto.X509.load_cert(x[0]).get_fingerprint(md='sha1')] = x[2] - except: - continue - - 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 = CONFIG.CERT_KEY_BACKUP + os.path.basename(row[2]) + "_" + row[0] - b_c = CONFIG.CERT_KEY_BACKUP + os.path.basename(row[1]) + "_" + row[0] - - c["orig_key_file"] = row[2] - c["orig_cert_file"] = row[1] - c["idx"] = int(row[0]) - c["backup_key_file"] = b_k - c["backup_cert_file"] = b_c - c["installed"] = c_sha1_vh.get(c["fingerprint"], "") - - 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.") - - def choose_certs(self, certs): - code, s = display.display_certs(certs) - if code == display.OK: - if display.confirm_revocation(certs[s]): - self.revoke(certs[s]) - else: - self.choose_certs(certs) - elif code == display.HELP: - print code, s, certs[s] - display.more_info_cert(certs[s]) - self.choose_certs(certs) - else: - exit(0) - - - def revocation_request(self, key_file, cert_der): - return {"type":"revocationRequest", - "certificate":le_util.b64_url_enc(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 = le_util.unique_file(CONFIG.CERT_PATH, 644) - 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) - - if certificate_dict.get("chain", None): - chain_fd, chain_fn = le_util.unique_file(CONFIG.CHAIN_PATH, 644) - for c in certificate_dict.get("chain", []): - 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) - # Enable any vhost that was issued to, but not enabled - if not host.enabled: - logger.info("Enabling Site " + host.file) - self.config.enable_site(host) - - # sites may have been enabled / final cleanup - self.config.restart(quiet=self.curses) - - display.success_installation(self.names) - - - def optimize_config(self, vhost): - if self.redirect is None: - self.redirect = display.redirect_by_default() - - if self.redirect: - self.redirect_to_ssl(vhost) - self.config.restart(quiet=self.curses) - - #if self.ocsp_stapling is None: - # q = "Would you like to protect the privacy of your users " + - # "by enabling OCSP stapling? If so, your users will not have to " + - # "query the Let's Encrypt CA separately about the current " + - # "revocation status of your certificate." - #self.ocsp_stapling = self.ocsp_stapling = display.ocsp_stapling(q) - #if self.ocsp_stapling: - # TODO enable OCSP Stapling - # continue - - - def certificate_request(self, csr_der, key): - logger.info("Preparing and sending CSR..") - return {"type":"certificateRequest", - "csr":le_util.b64_url_enc(csr_der), - "signature":crypto_util.create_sig(csr_der, self.key_file)} - - def cleanup_challenges(self, challenge_objs): - logger.info("Cleaning up challenges...") - for c in challenge_objs: - if c["type"] in CONFIG.CONFIG_CHALLENGES: - self.config.cleanup() - else: - #Handle other cleanup if needed - pass - - def is_expected_msg(self, msg_dict, expected, delay=3, rounds = 20): - 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", ""))) - raise errors.LetsEncryptClientError(msg_dict["error"]) - - elif msg_dict["type"] == "defer": - logger.info("Waiting for %d seconds..." % delay) - time.sleep(delay) - msg_dict = self.send(self.status_request(msg_dict["token"])) - else: - logger.fatal("Received unexpected message") - logger.fatal("Expected: %s" % expected) - logger.fatal("Received: " + msg_dict) - sys.exit(33) - - 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 + le_util.b64_url_dec(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)) - - logger.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], c["challenges"], path) - - responses = ["null"] * len(c["challenges"]) - - # Perform challenges - for i, c_obj in enumerate(challenge_objs): - response = "null" - if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - response = self.config.perform(c_obj) - else: - # Handle RecoveryToken type challenges - pass - - for index in indices[i]: - responses[index] = response - - 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 - TODO: Make sure that the challenges are feasible... - Example: Do you have the recovery key? - """ - - if combos: - 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 - """ - 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 c in combo: - combo_total += chall_cost.get(challenges[c]["type"], max_cost) - if combo_total < best_combo_cost: - best_combo = combo - best_combo_cost = combo_total - combo_total = 0 - - if not best_combo: - logger.fatal("Client does not support any combination of \ - challenges to satisfy ACME server") - sys.exit(22) - - return best_combo - - 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 - """ - # Add logic for a crappy server - # Choose a DV - path = [] - for pref_c in CONFIG.CHALLENGE_PREFERENCES: - for i, offered_c in enumerate(challenges): - 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] - - - def is_preferred(self, offered_c_type, path): - for tup in path: - for s in CONFIG.EXCLUSIVE_CHALLENGES: - # 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): - json_encoded = json.dumps(json_obj) + json_encoded = json.dumps(msg) acme.acme_object_validate(json_encoded) try: @@ -479,10 +221,10 @@ class Client(object): data=json_encoded, headers={"Content-Type": "application/json"}, ) - except requests.exceptions.RequestException as e: + except requests.exceptions.RequestException as error: logger.fatal("Send() failed... may have lost connection to server") logger.fatal(" ** ERROR **") - logger.fatal(e) + logger.fatal(error) sys.exit(8) try: @@ -497,52 +239,300 @@ class Client(object): return response.json() - def store_revocation_token(self, token): - return + def send_and_receive_expected(self, msg, expected): + """Send ACME message to server and return expected message. + :param msg: ACME message (JSON serializable). + :type acem_msg: dict + :param expected: Name of the expected response ACME message type. + :type expected: str - def store_cert_key(self, encrypt = False): - list_file = CONFIG.CERT_KEY_BACKUP + "LIST" + :returns: ACME response message of expected type. + :rtype: dict + + """ + response = self.send(msg) + try: + return self.is_expected_msg(response, expected) + except: # TODO: too generic exception + raise errors.LetsEncryptClientError( + 'Expected message (%s) not received' % expected) + + def is_expected_msg(self, response, expected, delay=3, rounds=20): + """Is reponse expected ACME message? + + :param response: ACME response message from server. + :type response: dict + + :param expected: Name of the expected response ACME message type. + :type expected: str + + :param delay: Number of seconds to delay before next round in case + of ACME "defer" response message. + :type delay: int + + :param rounds: Number of resend attempts in case of ACME "defer" + reponse message. + :type rounds: int + + :raises: Exception + + :returns: ACME response message from server. + :rtype: dict + + """ + for _ in xrange(rounds): + if response["type"] == expected: + return response + + elif response["type"] == "error": + logger.error("%s: %s - More Info: %s" % + (response["error"], + response.get("message", ""), + response.get("moreInfo", ""))) + raise errors.LetsEncryptClientError(response["error"]) + + elif response["type"] == "defer": + logger.info("Waiting for %d seconds..." % delay) + time.sleep(delay) + response = self.send(acme.status_request(response["token"])) + else: + logger.fatal("Received unexpected message") + logger.fatal("Expected: %s" % expected) + logger.fatal("Received: " + response) + sys.exit(33) + + logger.error("Server has deferred past the max of %d seconds" % + (rounds * delay)) + + def list_certs_keys(self): + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + certs = [] + + if not os.path.isfile(list_file): + logger.info( + "You don't have any certificates saved from letsencrypt") + return + + c_sha1_vh = {} + for (cert, _, path) in self.config.get_all_certs_keys(): + try: + c_sha1_vh[M2Crypto.X509.load_cert( + cert).get_fingerprint(md='sha1')] = path + except: + continue + + with open(list_file, 'rb') as csvfile: + csvreader = csv.reader(csvfile) + for row in csvreader: + cert = crypto_util.get_cert_info(row[1]) + + b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, + os.path.basename(row[2]) + "_" + row[0]) + b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, + os.path.basename(row[1]) + "_" + row[0]) + + cert.update({ + "orig_key_file": row[2], + "orig_cert_file": row[1], + "idx": int(row[0]), + "backup_key_file": b_k, + "backup_cert_file": b_c, + "installed": c_sha1_vh.get(cert["fingerprint"], ""), + }) + certs.append(cert) + if certs: + self.choose_certs(certs) + else: + display.generic_notification( + "There are not any trusted Let's Encrypt " + "certificates for this server.") + + def choose_certs(self, certs): + """Display choose certificates menu. + + :param certs: List of cert dicts. + :type certs: list + + """ + code, tag = display.display_certs(certs) + cert = certs[tag] + + if code == display.OK: + if display.confirm_revocation(cert): + self.acme_revocation(cert) + else: + self.choose_certs(certs) + elif code == display.HELP: + print code, tag, cert + display.more_info_cert(cert) + self.choose_certs(certs) + else: + exit(0) + + def install_certificate(self, certificate_dict, vhost): + """Install certificate + + :returns: Path to a certificate file. + :rtype: str + + """ + cert_chain_abspath = None + cert_fd, cert_file = le_util.unique_file(CONFIG.CERT_PATH, 644) + cert_fd.write( + crypto_util.b64_cert_to_pem(certificate_dict["certificate"])) + cert_fd.close() + logger.info("Server issued certificate; certificate written to %s" % + cert_file) + + if certificate_dict.get("chain", None): + chain_fd, chain_fn = le_util.unique_file(CONFIG.CHAIN_PATH, 644) + for cert in certificate_dict.get("chain", []): + chain_fd.write(crypto_util.b64_cert_to_pem(cert)) + 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(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) + self.config.enable_site(host) + + # sites may have been enabled / final cleanup + self.config.restart(quiet=self.curses) + + display.success_installation(self.names) + + return cert_file + + def optimize_config(self, vhost): + if self.redirect is None: + self.redirect = display.redirect_by_default() + + if self.redirect: + self.redirect_to_ssl(vhost) + self.config.restart(quiet=self.curses) + + # if self.ocsp_stapling is None: + # q = ("Would you like to protect the privacy of your users " + # "by enabling OCSP stapling? If so, your users will not have " + # "to query the Let's Encrypt CA separately about the current " + # "revocation status of your certificate.") + # self.ocsp_stapling = self.ocsp_stapling = display.ocsp_stapling(q) + # if self.ocsp_stapling: + # # TODO enable OCSP Stapling + # continue + + def cleanup_challenges(self, challenges): + logger.info("Cleaning up challenges...") + for chall in challenges: + if chall["type"] in CONFIG.CONFIG_CHALLENGES: + self.config.cleanup() + else: + # Handle other cleanup if needed + pass + + def verify_identity(self, challenge_msg): + """Verify identity. + + :param challenge_msg: ACME "challenge" message. + :type challenge_msg: dict + + :returns: TODO + :rtype: dict + + """ + path = challenge.gen_challenge_path( + challenge_msg["challenges"], challenge_msg.get("combinations", [])) + + logger.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): + response = "null" + if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: + response = self.config.perform(c_obj) + else: + # Handle RecoveryToken type challenges + pass + + for index in indices[i]: + responses[index] = response + + logger.info("Configured Apache for challenges; " + + "waiting for verification...") + + return responses, challenge_objs + + def store_cert_key(self, cert_file, encrypt=False): + """Store certificate key. + + :param cert_file: Path to a certificate file. + :type cert_file: str + + :param encrypt: Should the certificate key be encrypted? + :type encrypt: bool + + """ + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") le_util.make_or_verify_dir(CONFIG.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 + for row in csvreader: + idx = int(row[0]) + 1 csvwriter = csv.writer(csvfile) - csvwriter.writerow([str(idx), self.cert_file, self.key_file]) + csvwriter.writerow([str(idx), 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]) + csvwriter.writerow(["0", cert_file, self.key_file]) shutil.copy2(self.key_file, - CONFIG.CERT_KEY_BACKUP + os.path.basename(self.key_file) + - "_" + str(idx)) - shutil.copy2(self.cert_file, - CONFIG.CERT_KEY_BACKUP + os.path.basename(self.cert_file) + - "_" + str(idx)) - + os.path.join( + CONFIG.CERT_KEY_BACKUP, + os.path.basename(self.key_file) + "_" + str(idx))) + shutil.copy2(cert_file, + os.path.join( + CONFIG.CERT_KEY_BACKUP, + os.path.basename(cert_file) + "_" + str(idx))) def redirect_to_ssl(self, vhost): for ssl_vh in vhost: success, redirect_vhost = self.config.enable_redirect(ssl_vh) logger.info("\nRedirect vhost: " + redirect_vhost.file + - " - " + str(success)) + " - " + str(success)) # If successful, make sure redirect site is enabled if success: self.config.enable_site(redirect_vhost) - def get_virtual_hosts(self, domains): vhost = set() for name in domains: @@ -552,6 +542,23 @@ class Client(object): return vhost def challenge_factory(self, name, challenges, path): + """ + + :param name: TODO + :type name: TODO + + :param challanges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client + in order to prove possession of the identifier. + :type challenges: list + + :param path: List of indices from `challenges`. + :type path: list + + :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 @@ -559,17 +566,21 @@ class Client(object): challenge_objs = [] challenge_obj_indices = [] - for c in path: - if challenges[c]["type"] == "dvsni": - logger.info(" DVSNI challenge for name %s." % name) - sni_satisfies.append(c) - sni_todo.append( (str(name), str(challenges[c]["r"]), - str(challenges[c]["nonce"])) ) + for index in path: + chall = challenges[index] - elif challenges[c]["type"] == "recoveryToken": + if chall["type"] == "dvsni": + logger.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": logger.info("\tRecovery Token Challenge for name: %s." % name) - challenge_obj_indices.append(c) - challenge_objs.append({type:"recoveryToken"}) + challenge_obj_indices.append(index) + challenge_objs.append({ + type: "recoveryToken", + }) else: logger.fatal("Challenge not currently supported") @@ -578,15 +589,17 @@ class Client(object): 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", "listSNITuple":sni_todo, - "dvsni_key":os.path.abspath(self.key_file)}) + challenge_objs.append({ + "type": "dvsni", + "listSNITuple": sni_todo, + "dvsni_key": os.path.abspath(self.key_file), + }) challenge_obj_indices.append(sni_satisfies) logger.debug(sni_todo) return challenge_objs, challenge_obj_indices - - def get_key_csr_pem(self, csr_return_format = 'der'): + 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. @@ -600,7 +613,7 @@ class Client(object): # Save file le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0700) key_f, self.key_file = le_util.unique_file( - CONFIG.KEY_DIR + "key-letsencrypt.pem", 0600) + os.path.join(CONFIG.KEY_DIR, "key-letsencrypt.pem"), 0600) key_f.write(key_pem) key_f.close() logger.info("Generating key: %s" % self.key_file) @@ -616,12 +629,12 @@ class Client(object): # Save CSR le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0755) csr_f, self.csr_file = le_util.unique_file( - CONFIG.CERT_DIR + "csr-letsencrypt.pem", 0644) + os.path.join(CONFIG.CERT_DIR, "csr-letsencrypt.pem"), 0644) csr_f.write(csr_pem) csr_f.close() logger.info("Creating CSR: %s" % self.csr_file) else: - #TODO fix this der situation + # TODO fix this der situation try: csr_pem = open(self.csr_file).read().replace("\r", "") except: @@ -633,11 +646,12 @@ 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!" - # in_txt = "Enter the number of a Certificate Authority (c to cancel): " + # message = ("Pick a Certificate Authority. " + # "They're all unique and special!") + # in_txt = ("Enter the number of a Certificate Authority " + # "(c to cancel): ") # code, selection = display.generic_menu(message, choices, in_txt) # if code != display.OK: @@ -677,16 +691,15 @@ class Client(object): # return choices def get_all_names(self): - """ - Should return all valid names in the configuration - """ + """Return all valid names in the configuration.""" names = list(self.config.get_all_names()) - self.sanity_check_names(names) + sanity_check_names(names) if not names: logger.fatal("No domain names were found in your apache config") - logger.fatal("Either specify which names you would like letsencrypt \ - to validate or add server names to your virtual hosts") + logger.fatal("Either specify which names you would like " + "letsencrypt to validate or add server names " + "to your virtual hosts") sys.exit(1) return names @@ -699,65 +712,59 @@ class Client(object): logger.setLogger(logger.FileLogger(sys.stdout)) logger.setLogLevel(logger.INFO) - def sanity_check_names(self, names): - for name in names: - if not self.is_hostname_sane(name): - logger.fatal(repr(name) + " is an impossible hostname") - sys.exit(81) - def is_hostname_sane(self, hostname): - """ - Do enough to avoid shellcode from the environment. There's - no need to do more. - """ - allowed = string.ascii_letters + string.digits + "-." # hostnames & IPv4 - if all([c in allowed for c in hostname]): - return True +def remove_cert_key(cert): + """Remove certificate key. - if not allow_raw_ipv6_server: return False + :param cert: + :type cert: dict - # ipv6 is messy and complicated, can contain %zoneindex etc. - try: - # is this a valid IPv6 address? - socket.getaddrinfo(hostname,443,socket.AF_INET6) - return True - except: - return False + """ + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + + with open(list_file, 'rb') as orgfile: + csvreader = csv.reader(orgfile) + + with open(list_file2, 'wb') as newfile: + csvwriter = csv.writer(newfile) + + for row in csvreader: + if not (row[0] == str(cert["idx"]) and + row[1] == cert["orig_cert_file"] and + row[2] == cert["orig_key_file"]): + csvwriter.writerow(row) + + shutil.copy2(list_file2, list_file) + os.remove(list_file2) + os.remove(cert["backup_cert_file"]) + os.remove(cert["backup_key_file"]) -def old_cert(cert_filename, days_left): - cert = M2Crypto.X509.load_cert(cert_filename) - exp_time = cert.get_not_before().get_datetime() - cur_time = datetime.datetime.utcnow() +def sanity_check_names(names): + for name in names: + if not is_hostname_sane(name): + logger.fatal(repr(name) + " is an impossible hostname") + sys.exit(81) - # exp_time is returned in UTC time as defined by M2Crypto - # The datetime object is aware and cannot be compared to the naive utcnow() - # object. Thus, the tzinfo is stripped from exp_time assuming both objects - # are UTC. Base python doesn't seem to support instantiations of tzinfo - # objects without 3rd party support. It is easier just to strip tzinfo from - # exp_time rather than add the utc timezone to cur_time - if (exp_time.replace(tzinfo=None) - cur_time).days < days_left: + +def is_hostname_sane(hostname): + """ + Do enough to avoid shellcode from the environment. There's + no need to do more. + """ + # hostnames & IPv4 + allowed = string.ascii_letters + string.digits + "-." + if all([c in allowed for c in hostname]): return True - return False + if not ALLOW_RAW_IPV6_SERVER: + return False -def recognized_ca(issuer): - pass - -def gen_req_from_cert(): - return - - -def renew(config): - cert_key_pairs = config.get_all_certs_keys() - for tup in cert_key_pairs: - cert = M2Crypto.X509.load_cert(tup[0]) - issuer = cert.get_issuer() - if recognized_ca(issuer): - pass - # generate_renewal_req() - - # Wait for response, act accordingly - gen_req_from_cert() - -# vim: set expandtab tabstop=4 shiftwidth=4 + # ipv6 is messy and complicated, can contain %zoneindex etc. + try: + # is this a valid IPv6 address? + socket.getaddrinfo(hostname, 443, socket.AF_INET6) + return True + except: + return False diff --git a/letsencrypt/client/configurator.py b/letsencrypt/client/configurator.py index 7bce85ea5..30972c0e8 100644 --- a/letsencrypt/client/configurator.py +++ b/letsencrypt/client/configurator.py @@ -36,7 +36,9 @@ class Configurator(object): def get_all_certs_keys(self): """Retrieve all certs and keys set in configuration. - returns: list of tuples with form [(cert, key, path)] + :returns: List of tuples with form [(cert, key, path)]. + :rtype: list + """ raise NotImplementedError() diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 815e795cd..017b13e85 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -11,6 +11,7 @@ import M2Crypto from letsencrypt.client import CONFIG from letsencrypt.client import le_util +from letsencrypt.client import logger def b64_cert_to_pem(b64_der_cert): @@ -18,36 +19,54 @@ def b64_cert_to_pem(b64_der_cert): le_util.b64_url_dec(b64_der_cert)).as_pem() -def create_sig(msg, key_file, signer_nonce=None, - signer_nonce_len=CONFIG.NONCE_SIZE): - # DOES prepend signer_nonce to message - # TODO: Change this over to M2Crypto... PKey - # Protect against crypto unicode errors... is this sufficient? - # Do I need to escape? +def create_sig(msg, key_file, nonce=None, nonce_len=CONFIG.NONCE_SIZE): + """Create signature with nonce prepended to the message. + + TODO: Change this over to M2Crypto... PKey + Protect against crypto unicode errors... is this sufficient? + Do I need to escape? + + :param msg: Message to be signed + :type msg: Anything with __str__ method + + :param key_file: Path to a file containing RSA key. Accepted formats + are the same as for `Crypto.PublicKey.RSA.importKey`. + :type key_file: str + + :param nonce: Nonce to be used. If None, nonce of `nonce_len` size + will be randomly genereted. + :type nonce: str or None + + :param nonce_len: Size of the automaticaly generated nonce. + :type nonce_len: int + + :returns: Signature. + :rtype: dict + + """ msg = str(msg) key = Crypto.PublicKey.RSA.importKey(open(key_file).read()) - if signer_nonce is None: - signer_nonce = Random.get_random_bytes(signer_nonce_len) - hashed = Crypto.Hash.SHA256.new(signer_nonce + msg) - signer = Crypto.Signature.PKCS1_v1_5.new(key) - signature = signer.sign(hashed) - #print "signing:", signer_nonce + msg - #print "signature:", signature + nonce = Random.get_random_bytes(nonce_len) if nonce is None else nonce + + msg_with_nonce = nonce + msg + hashed = Crypto.Hash.SHA256.new(msg_with_nonce) + signature = Crypto.Signature.PKCS1_v1_5.new(key).sign(hashed) + + logger.debug('%s signed as %s' % (msg_with_nonce, signature)) + n_bytes = binascii.unhexlify(leading_zeros(hex(key.n)[2:].replace("L", ""))) e_bytes = binascii.unhexlify(leading_zeros(hex(key.e)[2:].replace("L", ""))) - n_encoded = le_util.b64_url_enc(n_bytes) - e_encoded = le_util.b64_url_enc(e_bytes) - signer_nonce_encoded = le_util.b64_url_enc(signer_nonce) - sig_encoded = le_util.b64_url_enc(signature) - jwk = {"kty": "RSA", "n": n_encoded, "e": e_encoded} - signature = { - "nonce": signer_nonce_encoded, + + return { + "nonce": le_util.b64_url_enc(nonce), "alg": "RS256", - "jwk": jwk, - "sig": sig_encoded + "jwk": { + "kty": "RSA", + "n": le_util.b64_url_enc(n_bytes), + "e": le_util.b64_url_enc(e_bytes), + }, + "sig": le_util.b64_url_enc(signature), } - # return json.dumps(signature) - return signature def leading_zeros(arg): @@ -151,6 +170,14 @@ def make_ss_cert(key_file, domains): def get_cert_info(filename): + """Get certificate info. + + :param filename: Name of file containing certificate in PEM format. + :type filename: str + + :rtype: dict + + """ # M2Crypto Library only supports RSA right now cert = M2Crypto.X509.load_cert(filename)