def __init__(self, config, key): log('You currently use ACMEv1 which is deprecated, consider using ACMEv2 (RFC8555) if at all possible.', warning=True) AbstractACMEAuthority.__init__(self, config, key) self.registered_account = False self.ca = config['authority'] self.agreement = config['authority_tos_agreement']
def __init__(self, config, key): AbstractACMEAuthority.__init__(self, config, key) # Initialize config vars self.ca = config['authority'] self.tos_agreed = str( config.get('authority_tos_agreement')).lower() == 'true' contact_email = config.get('authority_contact_email') if contact_email is None: self.contact = None elif isinstance(contact_email, list): self.contact = [ "mailto:{}".format(contact) for contact in contact_email ] else: self.contact = ["mailto:{}".format(contact_email)] # Initialize runtime vars code, self.directory, _ = self._request_url(self.ca + '/directory') if code >= 400 or not self.directory: self.directory = { "meta": {}, "newAccount": "{}/acme/new-acct".format(self.ca), "newNonce": "{}/acme/new-nonce".format(self.ca), "newOrder": "{}/acme/new-order".format(self.ca), "revokeCert": "{}/acme/revoke-cert".format(self.ca), } log("API directory retrieval failed ({}). Guessed necessary values: {}" .format(code, self.directory), warning=True) self.nonce = None self.nonce_time = 0 self.algorithm, jwk = tools.get_key_alg_and_jwk(key) self.account_protected = {"alg": self.algorithm, "jwk": jwk} self.account_id = None # will be updated to correct value during account registration
def revoke_crt(self, crt, reason=None): payload = {'certificate': tools.bytes_to_base64url(tools.convert_cert_to_der_bytes(crt))} if reason: payload['reason'] = int(reason) code, result, _ = self._request_acme_endpoint("revokeCert", payload) if code < 400: log("Revocation successful") else: raise ValueError("Revocation failed: {}".format(result))
def verify_dns_record(self, domain, txtvalue): if self.nsupdate_verify and not self.dns_verify_all_ns and not self.nsupdate_verified: # Verify master DNS only if we don't do a full NS check and it has not yet been verified _, nameserverip = self._determine_zone_and_nameserverip(domain) if self._check_txt_record_value(domain, txtvalue, nameserverip, use_tcp=True): log('Verified \'{} {} IN TXT "{}"\' on {}'.format(domain, self.dns_ttl, txtvalue, nameserverip)) self.nsupdate_verified = True else: # Master DNS verification failed. Return immediately and try again. return False return DNSChallengeHandler.verify_dns_record(self, domain, txtvalue)
def start_challenge(self, domain, thumbprint, token): domain = self._determine_challenge_domain(domain) txtvalue = self._determine_txtvalue(thumbprint, token) failtime = datetime.now() + timedelta(seconds=self.dns_verify_failtime) if self.verify_dns_record(domain, txtvalue): return else: log("Waiting until TXT record '{}' is ready".format(domain)) while failtime > datetime.now(): time.sleep(self.dns_verify_interval) if self.verify_dns_record(domain, txtvalue): return raise ValueError( "DNS challenge is not ready after waiting {} seconds".format( self.dns_verify_waittime))
def cert_revoke(cert, configs, fallback_authority, reason=None): domains = set(tools.get_cert_domains(cert)) acmeconfig = None for config in configs: if domains == set(config['domainlist']): acmeconfig = config['authority'] break if not acmeconfig: acmeconfig = fallback_authority log("No matching authority found to revoke {}: {}, using globalconfig/defaults".format(tools.get_cert_cn(cert), tools.get_cert_domains( cert)), warning=True) acme = authority(acmeconfig) acme.register_account() acme.revoke_crt(cert, reason)
def revoke_crt(self, crt, reason=None): header = self._prepare_header() payload = { "resource": "revoke-cert", "certificate": tools.bytes_to_base64url(tools.convert_cert_to_der_bytes(crt)) } if reason: payload['reason'] = int(reason) code, result = self._send_signed(self.ca + "/acme/revoke-cert", header, payload) if code < 400: log("Revocation successful") else: raise ValueError("Revocation failed: {}".format(result))
def authority(settings): key = json.dumps(settings, sort_keys=True) if key in authorities: return authorities[key] else: acc_file = settings['account_key'] if os.path.isfile(acc_file): log("Reading account key from {}".format(acc_file)) acc_key = tools.read_pem_file(acc_file, key=True) else: log("Account key not found at '{0}'. Creating key.".format(acc_file)) acc_key = tools.new_account_key(acc_file, settings['account_key_algorithm'], settings['account_key_length']) authority_module = importlib.import_module("acertmgr.authority.{0}".format(settings["api"])) authority_class = getattr(authority_module, "ACMEAuthority") authority_obj = authority_class(settings, acc_key) authorities[key] = authority_obj return authority_obj
def cert_put(settings): if 'path' not in settings: raise ValueError('Deployment settings are missing required element: path') if 'format' not in settings: raise ValueError('Deployment settings are missing required element: format') with io.open(settings['path'], "w+") as crt_fd: for fmt in [str.strip(x) for x in settings['format'].split(",")]: if fmt == "crt": with io.open(settings['cert_file'], "r") as src_fd: crt_fd.write(src_fd.read()) elif fmt == "key": with io.open(settings['key_file'], "r") as src_fd: crt_fd.write(src_fd.read()) elif fmt == "ca": with io.open(settings['ca_file'], "r") as src_fd: crt_fd.write(src_fd.read()) else: log("Ignored unknown deployment format key: {}".format(fmt), warning=True) # set owner and group if 'user' in settings or 'group' in settings: if 'pwd' in sys.modules and 'grp' in sys.modules and hasattr(os, 'chown') and hasattr(os, 'geteuid') and \ hasattr(os, 'getegid'): try: uid = pwd.getpwnam(settings['user']).pw_uid if 'user' in settings else os.geteuid() gid = grp.getgrnam(settings['group']).gr_gid if 'group' in settings else os.getegid() os.chown(settings['path'], uid, gid) except OSError as e: log('Could not set certificate file ownership', e, warning=True) else: log('File user and group handling unavailable on this platform', warning=True) # set permissions if 'perm' in settings: if hasattr(os, 'chmod'): try: os.chmod(settings['path'], int(settings['perm'], 8)) except OSError as e: log('Could not set certificate file permissions', e, warning=True) else: log('File permission handling unavailable on this platform', warning=True) return settings['action']
def cert_get(settings): log("Getting certificate for %s" % settings['domainlist']) acme = authority(settings['authority']) acme.register_account() # create challenge handlers for this certificate challenge_handlers = dict() for domain in settings['domainlist']: # Create the challenge handler challenge_handlers[domain] = challenge_handler(settings['handlers'][domain]) # create ssl key key_file = settings['key_file'] if os.path.isfile(key_file): key = tools.read_pem_file(key_file, key=True) else: log("SSL key not found at '{0}'. Creating key.".format(key_file)) key = tools.new_ssl_key(key_file, settings['key_algorithm'], settings['key_length']) # create ssl csr csr_file = settings['csr_file'] if os.path.isfile(csr_file) and str(settings['csr_static']).lower() == 'true': log('Loading CSR from {}'.format(csr_file)) cr = tools.read_pem_file(csr_file, csr=True) else: log('Generating CSR for {}'.format(settings['domainlist'])) must_staple = str(settings.get('cert_must_staple')).lower() == "true" cr = tools.new_cert_request(settings['domainlist'], key, must_staple) tools.write_pem_file(cr, csr_file) # request cert with csr crt, ca = acme.get_crt_from_csr(cr, settings['domainlist'], challenge_handlers) # if resulting certificate is valid: store in final location if tools.is_cert_valid(crt, settings['ttl_days']): log("Certificate '{}' renewed and valid until {}".format(tools.get_cert_cn(crt), tools.get_cert_valid_until(crt))) tools.write_pem_file(crt, settings['cert_file'], stat.S_IREAD) if (not str(settings.get('ca_static')).lower() == 'true' or not os.path.exists(settings['ca_file'])) \ and ca is not None: tools.write_pem_file(ca, settings['ca_file'])
def register_account(self): if self.registered_account: # We already have registered with this authority, just return return header = self._prepare_header() code, result = self._send_signed(self.ca + "/acme/new-reg", header, { "resource": "new-reg", "agreement": self.agreement, }) if code == 201: log("Registered!") self.registered_account = True return True elif code == 409: log("Already registered!") self.registered_account = True return False else: raise ValueError("Error registering: {0} {1}".format(code, result))
def register_account(self): if self.account_id: # We already have registered with this authority, just return return protected = copy.deepcopy(self.account_protected) payload = { "termsOfServiceAgreed": self.tos_agreed, "onlyReturnExisting": False, } if self.contact: payload["contact"] = self.contact code, result, headers = self._request_acme_endpoint("newAccount", payload, protected) if code < 400 and result['status'] == 'valid': self.account_id = headers['Location'] if 'meta' in self.directory and 'termsOfService' in self.directory['meta']: log("ToS at {} have been accepted.".format(self.directory['meta']['termsOfService'])) log("Account registered and valid on {}.".format(self.ca)) else: raise ValueError("Error registering account: {0} {1}".format(code, result))
def verify_dns_record(self, domain, txtvalue): if self.dns_verify_all_ns: try: nameserverip = None if self.dns_verify_server: # Use the specific dns server to determine NS for domain, will otherwise default to SOA master nameserverip = self._lookup_ip(self.dns_verify_server) ns_ip = self._lookup_ns_ip(domain, nameserverip) if len(ns_ip) > 0 and all( self._check_txt_record_value(domain, txtvalue, ip) for ip in ns_ip): # All NS servers have the necessary TXT record. Succeed immediately! log("All NS ({}) for '{}' have the correct TXT record". format(','.join(ns_ip), domain)) return True except (ValueError, dns.exception.DNSException): # Fall back to next verification pass if self.dns_verify_server and not self.dns_verify_all_ns: try: # Verify using specific dns server nameserverip = self._lookup_ip(self.dns_verify_server) if self._check_txt_record_value(domain, txtvalue, nameserverip): # Verify server confirms the necessary TXT record. Succeed immediately! log("DNS server '{}' found correct TXT record for '{}'". format(self.dns_verify_server, domain)) return True except (ValueError, dns.exception.DNSException): # Fall back to next verification pass if domain not in self._valid_times: # No valid wait time for domain. Verification fails! return False # Verification fails or succeeds based on valid wait time set by add_dns_record return datetime.now() >= self._valid_times[domain]
def log_message(self, fmt, *args): log("Request from '%s': %s" % (self.address_string(), fmt % args))
def get_crt_from_csr(self, csr, domains, challenge_handlers): account_thumbprint = tools.bytes_to_base64url( tools.hash_of_str( json.dumps(self.account_protected['jwk'], sort_keys=True, separators=(',', ':')))) log("Ordering certificate for {}".format(domains)) identifiers = [{'type': 'dns', 'value': domain} for domain in domains] code, order, headers = self._request_acme_endpoint( 'newOrder', {'identifiers': identifiers}) if code >= 400: raise ValueError("Error with certificate order: {0} {1}".format( code, order)) order_url = headers['Location'] authorizations = list() # verify each domain try: for authorizationUrl in order['authorizations']: # get new challenge code, authorization, _ = self._request_acme_url( authorizationUrl) if code >= 400: raise ValueError( "Error requesting authorization: {0} {1}".format( code, authorization)) authorization['_domain'] = "*.{}".format(authorization['identifier']['value']) if \ 'wildcard' in authorization and authorization['wildcard'] else authorization['identifier']['value'] if authorization.get('status', 'no-status-found') == 'valid': log("{} has already been authorized".format( authorization['_domain'])) continue if authorization['_domain'] not in challenge_handlers: raise ValueError( "No challenge handler given for domain: {0}".format( authorization['_domain'])) log("Authorizing {0}".format(authorization['_domain'])) # create the challenge ctype = challenge_handlers[ authorization['_domain']].get_challenge_type() matching_challenges = [ c for c in authorization['challenges'] if c['type'] == ctype ] if len(matching_challenges) == 0: raise ValueError( "Error no challenge matching {0} found: {1}".format( ctype, authorization)) authorization['_challenge'] = matching_challenges[0] if authorization['_challenge'].get( 'status', 'no-status-found') == 'valid': log("{} has already been authorized using {}".format( authorization['_domain'], ctype)) continue authorization['_token'] = re.sub( r"[^A-Za-z0-9_\-]", "_", authorization['_challenge']['token']) challenge_handlers[authorization['_domain']].create_challenge( authorization['identifier']['value'], account_thumbprint, authorization['_token']) authorizations.append(authorization) # after all challenges are created, start processing authorizations for authorization in authorizations: try: log("Starting verification of {}".format( authorization['_domain'])) challenge_handlers[ authorization['_domain']].start_challenge( authorization['identifier']['value'], account_thumbprint, authorization['_token']) # notify challenge is met code, challenge_status, _ = self._request_acme_url( authorization['_challenge']['url'], { "keyAuthorization": "{0}.{1}".format(authorization['_token'], account_thumbprint), }) # wait for challenge to be verified while code < 400 and challenge_status.get( 'status') == "pending": time.sleep(5) code, challenge_status, _ = self._request_acme_url( authorization['_challenge']['url']) if code < 400 and challenge_status.get( 'status') == "valid": log("{0} verified".format(authorization['_domain'])) else: raise ValueError( "{0} challenge did not pass ({1}): {2}".format( authorization['_domain'], code, challenge_status)) finally: challenge_handlers[ authorization['_domain']].stop_challenge( authorization['identifier']['value'], account_thumbprint, authorization['_token']) finally: # Destroy challenge handlers in reverse order to replay # any saved state information in the handlers correctly for authorization in reversed(authorizations): try: challenge_handlers[ authorization['_domain']].destroy_challenge( authorization['identifier']['value'], account_thumbprint, authorization['_token']) except Exception as e: log('Challenge destruction failed: {}'.format(e), error=True) # check order status and retry once code, order, _ = self._request_acme_url(order_url) if code < 400 and order.get('status') == 'pending': time.sleep(5) code, order, _ = self._request_acme_url(order_url) if code >= 400: raise ValueError( "Order is still not ready to be finalized: {0} {1}".format( code, order)) # get the new certificate log("Finalizing certificate") code, finalize, _ = self._request_acme_url( order['finalize'], { "csr": tools.bytes_to_base64url(tools.convert_cert_to_der_bytes(csr)), }) while code < 400 and (finalize.get('status') == 'pending' or finalize.get('status') == 'processing'): time.sleep(5) code, finalize, _ = self._request_acme_url(order_url) if code >= 400: raise ValueError("Error finalizing certificate: {0} {1}".format( code, finalize)) log("Certificate ready!") # return certificate code, certificate, _ = self._request_acme_url(finalize['certificate'], raw_result=True) if code >= 400: raise ValueError( "Error downloading certificate chain: {0} {1}".format( code, certificate)) cert_dict = re.match(( r'(?P<cert>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)\n\n' r'(?P<ca>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)?' ), certificate, re.DOTALL).groupdict() cert = tools.convert_pem_str_to_cert(cert_dict['cert']) if cert_dict['ca'] is None: ca = tools.download_issuer_ca(cert) else: ca = tools.convert_pem_str_to_cert(cert_dict['ca']) return cert, ca
def remove_dns_record(self, domain, txtvalue): zone, nameserverip = self._determine_zone_and_nameserverip(domain) update = dns.update.Update(zone, keyring=self.keyring, keyalgorithm=self.keyalgorithm) update.delete(domain, dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, txtvalue)) log('Deleting \'{} {} IN TXT "{}"\' from {}'.format(domain, self.dns_ttl, txtvalue, nameserverip)) dns.query.tcp(update, nameserverip, timeout=QUERY_TIMEOUT)
def get_crt_from_csr(self, csr, domains, challenge_handlers): header = self._prepare_header() account_thumbprint = tools.bytes_to_base64url( tools.hash_of_str( json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')))) challenges = dict() tokens = dict() authdomains = list() # verify each domain try: for domain in domains: log("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 authz = json.loads(result.decode('utf8')) if authz.get('status', 'no-status-found') == 'valid': log("{} has already been verified".format(domain)) continue challenges[domain] = [ c for c in authz['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)) challenge_handlers[domain].create_challenge( domain, account_thumbprint, tokens[domain]) authdomains.append(domain) # after all challenges are created, start processing authorizations for domain in authdomains: try: challenge_handlers[domain].start_challenge( domain, account_thumbprint, tokens[domain]) # notify challenge are met log("Starting key authorization") 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 = tools.get_url(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": log("{0} verified!".format(domain)) break else: raise ValueError( "{0} challenge did not pass: {1}".format( domain, challenge_status)) finally: challenge_handlers[domain].stop_challenge( domain, account_thumbprint, tokens[domain]) finally: # Destroy challenge handlers in reverse order to replay # any saved state information in the handlers correctly for domain in reversed(domains): try: challenge_handlers[domain].destroy_challenge( domain, account_thumbprint, tokens[domain]) except Exception as e: log('Challenge destruction failed: {}'.format(e), error=True) # get the new certificate log("Signing certificate...") code, result = self._send_signed( self.ca + "/acme/new-cert", header, { "resource": "new-cert", "csr": tools.bytes_to_base64url(tools.convert_cert_to_der_bytes(csr)), }) if code != 201: raise ValueError("Error signing certificate: {0} {1}".format( code, result)) # return signed certificate! log("Certificate signed!") cert = tools.convert_der_bytes_to_cert(result) return cert, tools.download_issuer_ca(cert)
def main(): # load config runtimeconfig, domainconfigs = configuration.load() # register idna-mapped domains as LOG_REPLACEMENTS for better readability of log output LOG_REPLACEMENTS.update({k: "{} [{}]".format(k, v) for k, v in domainconfigs['domainlist_idna_mapped']}) # Start processing if runtimeconfig.get('mode') == 'revoke': # Mode: revoke certificate log("Revoking {}".format(runtimeconfig['revoke'])) cert_revoke(tools.read_pem_file(runtimeconfig['revoke']), domainconfigs, runtimeconfig['fallback_authority'], runtimeconfig['revoke_reason']) else: # Mode: issue certificates (implicit) # post-update actions (run only once) actions = set() superseded = set() exceptions = list() # check certificate validity and obtain/renew certificates if needed for config in domainconfigs: try: cert = None if os.path.isfile(config['cert_file']): cert = tools.read_pem_file(config['cert_file']) validate_ocsp = str(config.get('validate_ocsp')).lower() != 'false' if validate_ocsp and cert and os.path.isfile(config['ca_file']): try: issuer = tools.read_pem_file(config['ca_file']) except Exception as e1: log("Failed to retrieve issuer from ca file: {}. Trying to download...".format(e1)) try: issuer = tools.download_issuer_ca(cert) except Exception as e2: log("Failed to download issuer for cert file: {}. Cannot validate OCSP.".format(e2)) validate_ocsp = False if not cert or ('force_renew' in runtimeconfig and all( d in config['domainlist'] for d in runtimeconfig['force_renew'])) \ or not tools.is_cert_valid(cert, config['ttl_days']) \ or (validate_ocsp and not tools.is_ocsp_valid(cert, issuer, config['validate_ocsp'])): cert_get(config) if str(config.get('cert_revoke_superseded')).lower() == 'true' and cert: superseded.add(cert) except Exception as e: log("Certificate issue/renew failed", e, error=True) exceptions.append(e) # deploy new certificates after all are renewed deployment_success = True for config in domainconfigs: for cfg in config['actions']: try: if not tools.target_is_current(cfg['path'], config['cert_file']): actions.add(cert_put(cfg)) log("Updated '{}' due to newer version".format(cfg['path'])) except Exception as e: log("Certificate deployment to {} failed".format(cfg['path']), e, error=True) exceptions.append(e) deployment_success = False # run post-update actions for action in actions: if action is not None: try: # Run actions in a shell environment (to allow shell syntax) as stated in the configuration output = subprocess.check_output(action, shell=True, stderr=subprocess.STDOUT) logmsg = "Action succeeded: {}".format(action) if len(output) > 0: if getattr(output, 'decode', None): # Decode function available? Use it to get a proper str output = output.decode('utf-8') logmsg += os.linesep + tools.indent(output, 18) # 18 = len("Action succeeded: ") log(logmsg) except subprocess.CalledProcessError as e: output = e.output logmsg = "Action failed: ({}) {}".format(e.returncode, e.cmd) if len(output) > 0: if getattr(output, 'decode', None): # Decode function available? Use it to get a proper str output = output.decode('utf-8') logmsg += os.linesep + tools.indent(output, 15) # 15 = len("Action failed: ") log(logmsg, error=True) exceptions.append(e) deployment_success = False # revoke old certificates as superseded if deployment_success: for superseded_cert in superseded: try: log("Revoking '{}' valid until {} as superseded".format( tools.get_cert_cn(superseded_cert), tools.get_cert_valid_until(superseded_cert))) cert_revoke(superseded_cert, domainconfigs, runtimeconfig['fallback_authority'], reason=4) except Exception as e: log("Certificate supersede revoke failed", e, error=True) exceptions.append(e) # throw a RuntimeError with all exceptions caught while working if there were any if len(exceptions) > 0: raise RuntimeError("{} exception(s) occurred during processing".format(len(exceptions)))
def add_dns_record(self, domain, txtvalue): zone, nameserverip = self._determine_zone_and_nameserverip(domain) update = dns.update.Update(zone, keyring=self.keyring, keyalgorithm=self.keyalgorithm) update.add(domain, self.dns_ttl, dns.rdatatype.TXT, txtvalue) log('Adding \'{} {} IN TXT "{}"\' to {}'.format(domain, self.dns_ttl, txtvalue, nameserverip)) dns.query.tcp(update, nameserverip, timeout=QUERY_TIMEOUT)