diff --git a/acertmgr.py b/acertmgr.py index 843b95e..52afd93 100755 --- a/acertmgr.py +++ b/acertmgr.py @@ -17,7 +17,6 @@ import stat import subprocess import tempfile -import acme import tools ACME_DIR = "/etc/acme" @@ -40,6 +39,25 @@ def target_is_current(target, crt_file): return target_date >= crt_date +# @brief create a authority for the given configuration +# @param config the authority configuration options +def create_authority(config): + if "apiversion" in config: + apiversion = config["apiversion"] + else: + apiversion = "v1" + + acc_file = config['account_key'] + if not os.path.isfile(acc_file): + print("Account key not found at '{0}'. Creating RSA key.".format(acc_file)) + tools.new_rsa_key(acc_file) + acc_key = tools.read_key(acc_file) + + authority_module = importlib.import_module("authority.{0}".format(apiversion)) + authority_class = getattr(authority_module, "ACMEAuthority") + return authority_class(config.get('authority'),acc_key) + + # @brief create a challenge handler for the given configuration # @param config the domain's configuration options def create_challenge_handler(config): @@ -65,10 +83,7 @@ def cert_get(domains, globalconfig, handlerconfigs): print("Server key not found at '{0}'. Creating RSA key.".format(key_file)) tools.new_rsa_key(key_file) - acc_file = globalconfig['account_key'] - if not os.path.isfile(acc_file): - print("Account key not found at '{0}'. Creating RSA key.".format(acc_file)) - tools.new_rsa_key(acc_file) + acme = create_authority(globalconfig) filename = hashlib.md5(domains).hexdigest() _, csr_file = tempfile.mkstemp(".csr", "%s." % filename) @@ -98,9 +113,8 @@ def cert_get(domains, globalconfig, handlerconfigs): key = tools.read_key(key_file) cr = tools.new_cert_request(domainlist, key) print("Reading account key...") - acc_key = tools.read_key(acc_file) - acme.register_account(acc_key, config['authority']) - crt = acme.get_crt_from_csr(acc_key, cr, domainlist, challenge_handlers, config['authority']) + acme.register_account() + crt = acme.get_crt_from_csr(cr, domainlist, challenge_handlers) with open(crt_file, "w") as crt_fd: crt_fd.write(tools.convert_cert_to_pem(crt)) diff --git a/acme.py b/acme.py deleted file mode 100644 index 55d060b..0000000 --- a/acme.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# acertmgr - acme api functions -# Copyright (c) Markus Hauschild & David Klaftenegger, 2016. -# Copyright (c) Rudolf Mayerhofer, 2019. -# available under the ISC license, see LICENSE - -import copy -import json -import re -import time - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding - -import tools -from tools import byte_string_format - -try: - from urllib.request import urlopen # Python 3 -except ImportError: - from urllib2 import urlopen # Python 2 - - -# @brief create the header information for ACME communication -# @param key the account key -# @return the header for ACME -def _prepare_header(key): - numbers = key.public_key().public_numbers() - header = { - "alg": "RS256", - "jwk": { - "e": tools.to_json_base64(byte_string_format(numbers.e)), - "kty": "RSA", - "n": tools.to_json_base64(byte_string_format(numbers.n)), - }, - } - return header - - -# @brief helper function to make signed requests -# @param CA the certificate authority -# @param url the request URL -# @param header the message header -# @param payload the message -# @return tuple of return code and request answer -def _send_signed(account_key, ca, url, header, payload): - payload64 = tools.to_json_base64(json.dumps(payload).encode('utf8')) - protected = copy.deepcopy(header) - protected["nonce"] = urlopen(ca + "/directory").headers['Replay-Nonce'] - protected64 = tools.to_json_base64(json.dumps(protected).encode('utf8')) - # @todo check why this padding is not working - # pad = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH) - pad = padding.PKCS1v15() - out = account_key.sign('.'.join([protected64, payload64]).encode('utf8'), pad, hashes.SHA256()) - data = json.dumps({ - "header": header, "protected": protected64, - "payload": payload64, "signature": tools.to_json_base64(out), - }) - try: - resp = urlopen(url, data.encode('utf8')) - return resp.getcode(), resp.read() - except IOError as e: - return getattr(e, "code", None), getattr(e, "read", e.__str__)() - - -# @brief register an account over ACME -# @param account_key the account key to register -# @param CA the certificate authority to register with -# @return True if new account was registered, False otherwise -def register_account(account_key, ca): - header = _prepare_header(account_key) - code, result = _send_signed(account_key, ca, ca + "/acme/new-reg", header, { - "resource": "new-reg", - "agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", - }) - if code == 201: - print("Registered!") - return True - elif code == 409: - print("Already registered!") - return False - else: - raise ValueError("Error registering: {0} {1}".format(code, result)) - - -# @brief function to fetch certificate using ACME -# @param account_key the account key in pyopenssl format -# @param csr the certificate signing request in pyopenssl format -# @param domains list of domains in the certificate, first is CN -# @param challenge_handlers a dict containing challenge for all given domains -# @param CA which signing CA to use -# @return the certificate in pyopenssl format -# @note algorithm and parts of the code are from acme-tiny -def get_crt_from_csr(account_key, csr, domains, challenge_handlers, ca): - header = _prepare_header(account_key) - accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) - account_hash = hashes.Hash(hashes.SHA256(), backend=default_backend()) - account_hash.update(accountkey_json.encode('utf8')) - account_thumbprint = tools.to_json_base64(account_hash.finalize()) - - # verify each domain - for domain in domains: - print("Verifying {0}...".format(domain)) - - # get new challenge - code, result = _send_signed(account_key, ca, ca + "/acme/new-authz", header, { - "resource": "new-authz", - "identifier": {"type": "dns", "value": domain}, - }) - if code != 201: - raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) - - # create the challenge - challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if - c['type'] == challenge_handlers[domain].get_challenge_type()][0] - token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) - - if domain not in challenge_handlers: - raise ValueError("No challenge handler given for domain: {0}".format(domain)) - - challenge_handlers[domain].create_challenge(domain, account_thumbprint, token) - - # notify challenge are met - keyauthorization = "{0}.{1}".format(token, account_thumbprint) - code, result = _send_signed(account_key, ca, challenge['uri'], header, { - "resource": "challenge", - "keyAuthorization": keyauthorization, - }) - if code != 202: - raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) - - # wait for challenge to be verified - while True: - try: - resp = urlopen(challenge['uri']) - challenge_status = json.loads(resp.read().decode('utf8')) - except IOError as e: - raise ValueError("Error checking challenge: {0} {1}".format( - e.code, json.loads(e.read().decode('utf8')))) - if challenge_status['status'] == "pending": - time.sleep(2) - elif challenge_status['status'] == "valid": - print("{0} verified!".format(domain)) - challenge_handlers[domain].destroy_challenge(domain, account_thumbprint, token) - break - else: - raise ValueError("{0} challenge did not pass: {1}".format( - domain, challenge_status)) - - # get the new certificate - print("Signing certificate...") - csr_der = csr.public_bytes(serialization.Encoding.DER) - code, result = _send_signed(account_key, ca, ca + "/acme/new-cert", header, { - "resource": "new-cert", - "csr": tools.to_json_base64(csr_der), - }) - if code != 201: - raise ValueError("Error signing certificate: {0} {1}".format(code, result)) - - # return signed certificate! - print("Certificate signed!") - cert = x509.load_der_x509_certificate(result, default_backend()) - return cert diff --git a/authority/__init__.py b/authority/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authority/acme.py b/authority/acme.py new file mode 100644 index 0000000..70dac2c --- /dev/null +++ b/authority/acme.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# acertmgr - generic acme api functions +# Copyright (c) Markus Hauschild & David Klaftenegger, 2016. +# Copyright (c) Rudolf Mayerhofer, 2019. +# available under the ISC license, see LICENSE + + +class ACMEAuthority: + # @brief Init class with config + # @param ca Certificate authority uri + # @param account_key Account key file + def __init__(self, ca, key): + self.ca = ca + self.key = key + + # @brief register an account over ACME + # @param account_key the account key to register + # @param CA the certificate authority to register with + # @return True if new account was registered, False otherwise + def register_account(self): + raise NotImplementedError + + # @brief function to fetch certificate using ACME + # @param account_key the account key in pyopenssl format + # @param csr the certificate signing request in pyopenssl format + # @param domains list of domains in the certificate, first is CN + # @param challenge_handlers a dict containing challenge for all given domains + # @param CA which signing CA to use + # @return the certificate in pyopenssl format + # @note algorithm and parts of the code are from acme-tiny + def get_crt_from_csr(self, csr, domains, challenge_handlers): + raise NotImplementedError diff --git a/authority/v1.py b/authority/v1.py new file mode 100644 index 0000000..e180754 --- /dev/null +++ b/authority/v1.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# acertmgr - acme api v1 functions +# Copyright (c) Markus Hauschild & David Klaftenegger, 2016. +# Copyright (c) Rudolf Mayerhofer, 2019. +# available under the ISC license, see LICENSE + +import copy +import datetime +import json +import re +import time + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + +import tools +from tools import byte_string_format + +try: + from urllib.request import urlopen # Python 3 +except ImportError: + from urllib2 import urlopen # Python 2 + +from authority.acme import ACMEAuthority as AbstractACMEAuthority + + +class ACMEAuthority(AbstractACMEAuthority): + # @brief create the header information for ACME communication + # @param key the account key + # @return the header for ACME + def _prepare_header(self): + numbers = self.key.public_key().public_numbers() + header = { + "alg": "RS256", + "jwk": { + "e": tools.to_json_base64(byte_string_format(numbers.e)), + "kty": "RSA", + "n": tools.to_json_base64(byte_string_format(numbers.n)), + }, + } + return header + + # @brief helper function to make signed requests + # @param url the request URL + # @param header the message header + # @param payload the message + # @return tuple of return code and request answer + def _send_signed(self, url, header, payload): + payload64 = tools.to_json_base64(json.dumps(payload).encode('utf8')) + protected = copy.deepcopy(header) + protected["nonce"] = urlopen(self.ca + "/directory").headers['Replay-Nonce'] + protected64 = tools.to_json_base64(json.dumps(protected).encode('utf8')) + # @todo check why this padding is not working + # pad = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH) + pad = padding.PKCS1v15() + out = self.key.sign('.'.join([protected64, payload64]).encode('utf8'), pad, hashes.SHA256()) + data = json.dumps({ + "header": header, "protected": protected64, + "payload": payload64, "signature": tools.to_json_base64(out), + }) + try: + resp = urlopen(url, data.encode('utf8')) + return resp.getcode(), resp.read() + except IOError as e: + return getattr(e, "code", None), getattr(e, "read", e.__str__)() + + # @brief register an account over ACME + # @return True if new account was registered, False otherwise + def register_account(self): + header = self._prepare_header() + code, result = self._send_signed(self.ca + "/acme/new-reg", header, { + "resource": "new-reg", + "agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", + }) + if code == 201: + print("Registered!") + return True + elif code == 409: + print("Already registered!") + return False + else: + raise ValueError("Error registering: {0} {1}".format(code, result)) + + # @brief function to fetch certificate using ACME + # @param csr the certificate signing request in pyopenssl format + # @param domains list of domains in the certificate, first is CN + # @param challenge_handlers a dict containing challenge for all given domains + # @return the certificate in pyopenssl format + # @note algorithm and parts of the code are from acme-tiny + def get_crt_from_csr(self, csr, domains, challenge_handlers): + header = self._prepare_header() + accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) + account_hash = hashes.Hash(hashes.SHA256(), backend=default_backend()) + account_hash.update(accountkey_json.encode('utf8')) + account_thumbprint = tools.to_json_base64(account_hash.finalize()) + + challenges = dict() + tokens = dict() + valid_times = list() + # verify each domain + try: + for domain in domains: + print("Verifying {0}...".format(domain)) + + # get new challenge + code, result = self._send_signed(self.ca + "/acme/new-authz", header, { + "resource": "new-authz", + "identifier": {"type": "dns", "value": domain}, + }) + if code != 201: + raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) + + # create the challenge + challenges[domain] = [c for c in json.loads(result.decode('utf8'))['challenges'] if + c['type'] == challenge_handlers[domain].get_challenge_type()][0] + tokens[domain] = re.sub(r"[^A-Za-z0-9_\-]", "_", challenges[domain]['token']) + + if domain not in challenge_handlers: + raise ValueError("No challenge handler given for domain: {0}".format(domain)) + + valid_times.append( + challenge_handlers[domain].create_challenge(domain, account_thumbprint, tokens[domain])) + + print("Waiting until challenges are valid ({})".format(",".join([str(x) for x in valid_times]))) + for valid_time in valid_times: + while datetime.datetime.now() < valid_time: + time.sleep(1) + + for domain in domains: + print("Starting key authorization") + # notify challenge are met + keyauthorization = "{0}.{1}".format(tokens[domain], account_thumbprint) + code, result = self._send_signed(challenges[domain]['uri'], header, { + "resource": "challenge", + "keyAuthorization": keyauthorization, + }) + if code != 202: + raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) + + # wait for challenge to be verified + while True: + try: + resp = urlopen(challenges[domain]['uri']) + challenge_status = json.loads(resp.read().decode('utf8')) + except IOError as e: + raise ValueError("Error checking challenge: {0} {1}".format( + e.code, json.loads(e.read().decode('utf8')))) + if challenge_status['status'] == "pending": + time.sleep(2) + elif challenge_status['status'] == "valid": + print("{0} verified!".format(domain)) + break + else: + raise ValueError("{0} challenge did not pass: {1}".format( + domain, challenge_status)) + finally: + for domain in domains: + try: + challenge_handlers[domain].destroy_challenge(domain, account_thumbprint, tokens[domain]) + except: + pass + + # get the new certificate + print("Signing certificate...") + csr_der = csr.public_bytes(serialization.Encoding.DER) + code, result = self._send_signed(self.ca + "/acme/new-cert", header, { + "resource": "new-cert", + "csr": tools.to_json_base64(csr_der), + }) + if code != 201: + raise ValueError("Error signing certificate: {0} {1}".format(code, result)) + + # return signed certificate! + print("Certificate signed!") + cert = x509.load_der_x509_certificate(result, default_backend()) + return cert diff --git a/modes/abstract.py b/modes/abstract.py index 1533802..55f0f82 100644 --- a/modes/abstract.py +++ b/modes/abstract.py @@ -14,6 +14,7 @@ class AbstractChallengeHandler: def get_challenge_type(): raise NotImplemented + # @return datetime after which the challenge is valid def create_challenge(self, domain, thumbprint, token): raise NotImplemented diff --git a/modes/standalone.py b/modes/standalone.py index 11784b6..35b1397 100644 --- a/modes/standalone.py +++ b/modes/standalone.py @@ -20,6 +20,7 @@ import os import threading from modes.webdir import ChallengeHandler as WebChallengeHandler +import datetime # @brief custom request handler for ACME challenges @@ -74,6 +75,7 @@ class ChallengeHandler(WebChallengeHandler): self.server_thread = threading.Thread(target=start_standalone, args=(self.server,)) os.chdir(self.challenge_directory) self.server_thread.start() + return datetime.datetime.now() def destroy_challenge(self, domain, thumbprint, token): self.server.shutdown() diff --git a/modes/webdir.py b/modes/webdir.py index c69a70f..1be2dcc 100644 --- a/modes/webdir.py +++ b/modes/webdir.py @@ -5,10 +5,11 @@ # Copyright (c) Rudolf Mayerhofer, 2019. # available under the ISC license, see LICENSE +import datetime import os from modes.abstract import AbstractChallengeHandler - +import datetime try: from urllib.request import urlopen # Python 3 except ImportError: @@ -42,6 +43,7 @@ class ChallengeHandler(AbstractChallengeHandler): os.remove(wellknown_path) raise ValueError("Wrote file to {0}, but couldn't download {1}".format( wellknown_path, wellknown_url)) + return datetime.datetime.now() def destroy_challenge(self, domain, thumbprint, token): os.remove(os.path.join(self.challenge_directory, token))