def get_cert_days(module, cert_file): ''' Return the days the certificate in cert_file remains valid and -1 if the file was not found. If cert_file contains more than one certificate, only the first one will be considered. ''' if HAS_CURRENT_CRYPTOGRAPHY: return cryptography_get_cert_days(module, cert_file) if not os.path.exists(cert_file): return -1 openssl_bin = module.get_bin_path('openssl', True) openssl_cert_cmd = [ openssl_bin, "x509", "-in", cert_file, "-noout", "-text" ] dummy, out, dummy = module.run_command(openssl_cert_cmd, check_rc=True, encoding=None) try: not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1) not_after = datetime.fromtimestamp( time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z'))) except AttributeError: raise ModuleFailException( "No 'Not after' date found in {0}".format(cert_file)) except ValueError: raise ModuleFailException( "Failed to parse 'Not after' date of {0}".format(cert_file)) now = datetime.utcnow() return (not_after - now).days
def _finalize_cert(self): ''' Create a new certificate based on the csr. Return the certificate object as dict https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.4 ''' openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-outform", "DER"] dummy, out, dummy = self.module.run_command(openssl_csr_cmd, check_rc=True) new_cert = { "csr": nopad_b64(to_bytes(out)), } result, info = self.account.send_signed_request(self.finalize_uri, new_cert) if info['status'] not in [200]: raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) order = info['location'] status = result['status'] while status not in ['valid', 'invalid']: time.sleep(2) result = simple_get(self.module, order) status = result['status'] if status != 'valid': raise ModuleFailException("Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result)) return result['certificate']
def _finalize_cert(self): ''' Create a new certificate based on the csr. Return the certificate object as dict https://tools.ietf.org/html/rfc8555#section-7.4 ''' csr = pem_to_der(self.csr) new_cert = { "csr": nopad_b64(csr), } result, info = self.account.send_signed_request( self.finalize_uri, new_cert) if info['status'] not in [200]: raise ModuleFailException( "Error new cert: CODE: {0} RESULT: {1}".format( info['status'], result)) order = info['location'] status = result['status'] while status not in ['valid', 'invalid']: time.sleep(2) result, dummy = self.account.get_request(order) status = result['status'] if status != 'valid': raise ModuleFailException( "Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format( info['status'], status, result)) return result['certificate']
def _download_cert(self, url): ''' Download and parse the certificate chain. https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.4.2 ''' resp, info = fetch_url( self.module, url, headers={'Accept': 'application/pem-certificate-chain'}) try: content = resp.read() except AttributeError: content = info.get('body') if not content or not info['content-type'].startswith( 'application/pem-certificate-chain'): raise ModuleFailException( "Cannot download certificate chain from {0}: {1} (headers: {2})" .format(url, content, info)) cert = None chain = [] # Parse data lines = content.decode('utf-8').splitlines(True) current = [] for line in lines: if line.strip(): current.append(line) if line.startswith('-----END CERTIFICATE-----'): if cert is None: cert = ''.join(current) else: chain.append(''.join(current)) current = [] # Process link-up headers if there was no chain in reply if not chain and 'link' in info: link = info['link'] parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link) if parsed_link and parsed_link.group(2) == "up": chain_link = parsed_link.group(1) chain_result, chain_info = fetch_url(self.module, chain_link, method='GET') if chain_info['status'] in [200, 201]: chain.append(self._der_to_pem(chain_result.read())) if cert is None or current: raise ModuleFailException( "Failed to parse certificate chain download from {0}: {1} (headers: {2})" .format(url, content, info)) return {'cert': cert, 'chain': chain}
def _download_cert(self, url): ''' Download and parse the certificate chain. https://tools.ietf.org/html/rfc8555#section-7.4.2 ''' content, info = self.account.get_request( url, parse_json_result=False, headers={'Accept': 'application/pem-certificate-chain'}) if not content or not info['content-type'].startswith( 'application/pem-certificate-chain'): raise ModuleFailException( "Cannot download certificate chain from {0}: {1} (headers: {2})" .format(url, content, info)) cert = None chain = [] # Parse data lines = content.decode('utf-8').splitlines(True) current = [] for line in lines: if line.strip(): current.append(line) if line.startswith('-----END CERTIFICATE-----'): if cert is None: cert = ''.join(current) else: chain.append(''.join(current)) current = [] alternates = [] def f(link, relation): if relation == 'up': # Process link-up headers if there was no chain in reply if not chain: chain_result, chain_info = self.account.get_request( link, parse_json_result=False) if chain_info['status'] in [200, 201]: chain.append(self._der_to_pem(chain_result)) elif relation == 'alternate': alternates.append(link) process_links(info, f) if cert is None or current: raise ModuleFailException( "Failed to parse certificate chain download from {0}: {1} (headers: {2})" .format(url, content, info)) return {'cert': cert, 'chain': chain, 'alternates': alternates}
def __init__(self, module): self.module = module self.version = module.params['acme_version'] self.challenge = module.params['challenge'] self.csr = module.params['csr'] self.dest = module.params.get('dest') self.fullchain_dest = module.params.get('fullchain_dest') self.chain_dest = module.params.get('chain_dest') self.account = ACMEAccount(module) self.directory = self.account.directory self.data = module.params['data'] self.authorizations = None self.cert_days = -1 self.order_uri = self.data.get('order_uri') if self.data else None self.finalize_uri = None # Make sure account exists modify_account = module.params['modify_account'] if modify_account or self.version > 1: contact = [] if module.params['account_email']: contact.append('mailto:' + module.params['account_email']) created, account_data = self.account.setup_account( contact, agreement=module.params.get('agreement'), terms_agreed=module.params.get('terms_agreed'), allow_creation=modify_account, ) if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') updated = False if not created and account_data and modify_account: updated, account_data = self.account.update_account( account_data, contact) self.changed = created or updated else: # This happens if modify_account is False and the ACME v1 # protocol is used. In this case, we do not call setup_account() # to avoid accidental creation of an account. This is OK # since for ACME v1, the account URI is not needed to send a # signed ACME request. pass if not os.path.exists(self.csr): raise ModuleFailException("CSR %s not found" % (self.csr)) self._openssl_bin = module.get_bin_path('openssl', True) # Extract list of identifiers from CSR self.identifiers = self._get_csr_identifiers()
def _download_cert(self, url): ''' Download and parse the certificate chain. https://tools.ietf.org/html/rfc8555#section-7.4.2 ''' content, info = self.account.get_request( url, parse_json_result=False, headers={'Accept': 'application/pem-certificate-chain'}) if not content or not info['content-type'].startswith( 'application/pem-certificate-chain'): raise ModuleFailException( "Cannot download certificate chain from {0}: {1} (headers: {2})" .format(url, content, info)) cert = None chain = [] # Parse data lines = content.decode('utf-8').splitlines(True) current = [] for line in lines: if line.strip(): current.append(line) if line.startswith('-----END CERTIFICATE-----'): if cert is None: cert = ''.join(current) else: chain.append(''.join(current)) current = [] # Process link-up headers if there was no chain in reply if not chain and 'link' in info: link = info['link'] parsed_link = re.match(r'<([^>]+)>;\s*rel="(\w+)"', link) if parsed_link and parsed_link.group(2) == "up": chain_link = parsed_link.group(1) chain_result, chain_info = self.account.get_request( chain_link, parse_json_result=False) if chain_info['status'] in [200, 201]: chain.append(self._der_to_pem(chain_result)) if cert is None or current: raise ModuleFailException( "Failed to parse certificate chain download from {0}: {1} (headers: {2})" .format(url, content, info)) return {'cert': cert, 'chain': chain}
def _new_cert_v1(self): ''' Create a new certificate based on the CSR (ACME v1 protocol). Return the certificate object as dict https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 ''' openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-outform", "DER"] dummy, out, dummy = self.module.run_command(openssl_csr_cmd, check_rc=True) new_cert = { "resource": "new-cert", "csr": nopad_b64(to_bytes(out)), } result, info = self.account.send_signed_request(self.directory['new-cert'], new_cert) chain = [] if 'link' in info: link = info['link'] parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link) if parsed_link and parsed_link.group(2) == "up": chain_link = parsed_link.group(1) chain_result, chain_info = fetch_url(self.module, chain_link, method='GET') if chain_info['status'] in [200, 201]: chain = [self._der_to_pem(chain_result.read())] if info['status'] not in [200, 201]: raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) else: return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain}
def _new_order_v2(self): ''' Start a new certificate order (ACME v2 protocol). https://tools.ietf.org/html/rfc8555#section-7.4 ''' identifiers = [] for domain in self.domains: identifiers.append({ 'type': 'dns', 'value': domain, }) new_order = {"identifiers": identifiers} result, info = self.account.send_signed_request( self.directory['newOrder'], new_order) if info['status'] not in [201]: raise ModuleFailException( "Error new order: CODE: {0} RESULT: {1}".format( info['status'], result)) for auth_uri in result['authorizations']: auth_data, dummy = self.account.get_request(auth_uri) auth_data['uri'] = auth_uri domain = auth_data['identifier']['value'] if auth_data.get('wildcard', False): domain = '*.{0}'.format(domain) self.authorizations[domain] = auth_data self.order_uri = info['location'] self.finalize_uri = result['finalize']
def _new_cert_v1(self): ''' Create a new certificate based on the CSR (ACME v1 protocol). Return the certificate object as dict https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 ''' csr = pem_to_der(self.csr) new_cert = { "resource": "new-cert", "csr": nopad_b64(csr), } result, info = self.account.send_signed_request( self.directory['new-cert'], new_cert) chain = [] if 'link' in info: link = info['link'] parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link) if parsed_link and parsed_link.group(2) == "up": chain_link = parsed_link.group(1) chain_result, chain_info = self.account.get_request( chain_link, parse_json_result=False) if chain_info['status'] in [200, 201]: chain = [self._der_to_pem(chain_result)] if info['status'] not in [200, 201]: raise ModuleFailException( "Error new cert: CODE: {0} RESULT: {1}".format( info['status'], result)) else: return { 'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain }
def _new_authz_v1(self, domain): ''' Create a new authorization for the given domain. Return the authorization object of the new authorization https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 ''' if self.account.uri is None: return new_authz = { "resource": "new-authz", "identifier": { "type": "dns", "value": domain }, } result, info = self.account.send_signed_request( self.directory['new-authz'], new_authz) if info['status'] not in [200, 201]: raise ModuleFailException( "Error requesting challenges: CODE: {0} RESULT: {1}".format( info['status'], result)) else: result['uri'] = info['location'] return result
def _new_cert_v1(self): ''' Create a new certificate based on the CSR (ACME v1 protocol). Return the certificate object as dict https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 ''' csr = pem_to_der(self.csr) new_cert = { "resource": "new-cert", "csr": nopad_b64(csr), } result, info = self.account.send_signed_request(self.directory['new-cert'], new_cert) chain = [] def f(link, relation): if relation == 'up': chain_result, chain_info = self.account.get_request(link, parse_json_result=False) if chain_info['status'] in [200, 201]: chain.clear() chain.append(self._der_to_pem(chain_result)) process_links(info, f) if info['status'] not in [200, 201]: raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) else: return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain}
def finish_challenges(self): ''' Verify challenges for all domains of the CSR. ''' self.authorizations = {} # Step 1: obtain challenge information if self.version == 1: # For ACME v1, we attempt to create new authzs. Existing ones # will be returned instead. for domain in self.domains: new_auth = self._new_authz_v1(domain) self._add_or_update_auth(domain, new_auth) else: # For ACME v2, we obtain the order object by fetching the # order URI, and extract the information from there. resp, info = fetch_url(self.module, self.order_uri) try: result = resp.read() except AttributeError: result = info.get('body') if not result: raise ModuleFailException( "Cannot download order from {0}: {1} (headers: {2})". format(self.order_uri, result, info)) if info['status'] not in [200]: raise ModuleFailException( "Error on downloading order: CODE: {0} RESULT: {1}".format( info['status'], result)) result = self.module.from_json(result.decode('utf8')) for auth_uri in result['authorizations']: auth_data = simple_get(self.module, auth_uri) auth_data['uri'] = auth_uri domain = auth_data['identifier']['value'] if auth_data.get('wildcard', False): domain = '*.{0}'.format(domain) self.authorizations[domain] = auth_data self.finalize_uri = result['finalize'] # Step 2: validate challenges for domain, auth in self.authorizations.items(): if auth['status'] == 'pending': self._validate_challenges(domain, auth)
def finish_challenges(self): ''' Verify challenges for all identifiers of the CSR. ''' self.authorizations = {} # Step 1: obtain challenge information if self.version == 1: # For ACME v1, we attempt to create new authzs. Existing ones # will be returned instead. for identifier_type, identifier in self.identifiers: new_auth = self._new_authz_v1(identifier_type, identifier) self._add_or_update_auth(identifier_type, identifier, new_auth) else: # For ACME v2, we obtain the order object by fetching the # order URI, and extract the information from there. result, info = self.account.get_request(self.order_uri) if not result: raise ModuleFailException( "Cannot download order from {0}: {1} (headers: {2})". format(self.order_uri, result, info)) if info['status'] not in [200]: raise ModuleFailException( "Error on downloading order: CODE: {0} RESULT: {1}".format( info['status'], result)) for auth_uri in result['authorizations']: auth_data, dummy = self.account.get_request(auth_uri) auth_data['uri'] = auth_uri identifier_type = auth_data['identifier']['type'] identifier = auth_data['identifier']['value'] if auth_data.get('wildcard', False): identifier = '*.{0}'.format(identifier) self.authorizations[identifier_type + ':' + identifier] = auth_data self.finalize_uri = result['finalize'] # Step 2: validate challenges for type_identifier, auth in self.authorizations.items(): if auth['status'] == 'pending': identifier_type, identifier = type_identifier.split(':', 1) self._validate_challenges(identifier_type, identifier, auth)
def get_certificate(self): ''' Request a new certificate and write it to the destination file. First verifies whether all authorizations are valid; if not, aborts with an error. ''' for identifier_type, identifier in self.identifiers: auth = self.authorizations.get(identifier_type + ':' + identifier) if auth is None: raise ModuleFailException('Found no authorization information for "{0}"!'.format(identifier_type + ':' + identifier)) if 'status' not in auth: self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned no status') if auth['status'] != 'valid': self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned status ' + str(auth['status'])) if self.version == 1: cert = self._new_cert_v1() else: cert_uri = self._finalize_cert() cert = self._download_cert(cert_uri) if self.module.params['retrieve_all_alternates']: alternate_chains = [] for alternate in cert['alternates']: try: alt_cert = self._download_cert(alternate) except ModuleFailException as e: self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e)) continue alternate_chains.append(alt_cert) self.all_chains = [] def _append_all_chains(cert_data): self.all_chains.append(dict( cert=cert_data['cert'].encode('utf8'), chain=("\n".join(cert_data.get('chain', []))).encode('utf8'), full_chain=(cert_data['cert'] + "\n".join(cert_data.get('chain', []))).encode('utf8'), )) _append_all_chains(cert) for alt_chain in alternate_chains: _append_all_chains(alt_chain) if cert['cert'] is not None: pem_cert = cert['cert'] chain = [link for link in cert.get('chain', [])] if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')): self.cert_days = get_cert_days(self.module, self.dest) self.changed = True if self.fullchain_dest and write_file(self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')): self.cert_days = get_cert_days(self.module, self.fullchain_dest) self.changed = True if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')): self.changed = True
def get_certificate(self): ''' Request a new certificate and write it to the destination file. First verifies whether all authorizations are valid; if not, aborts with an error. ''' for identifier_type, identifier in self.identifiers: auth = self.authorizations.get(identifier_type + ':' + identifier) if auth is None: raise ModuleFailException( 'Found no authorization information for "{0}"!'.format( identifier_type + ':' + identifier)) if 'status' not in auth: self._fail_challenge( identifier_type, identifier, auth, 'Authorization for {0} returned no status') if auth['status'] != 'valid': self._fail_challenge( identifier_type, identifier, auth, 'Authorization for {0} returned status ' + str(auth['status'])) if self.version == 1: cert = self._new_cert_v1() else: cert_uri = self._finalize_cert() cert = self._download_cert(cert_uri) if cert['cert'] is not None: pem_cert = cert['cert'] chain = [link for link in cert.get('chain', [])] if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')): self.cert_days = get_cert_days(self.module, self.dest) self.changed = True if self.fullchain_dest and write_file( self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')): self.cert_days = get_cert_days(self.module, self.fullchain_dest) self.changed = True if self.chain_dest and write_file( self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')): self.changed = True
def _fail_challenge(self, identifier_type, identifier, auth, error): ''' Aborts with a specific error for a challenge. ''' error_details = '' # multiple challenges could have failed at this point, gather error # details for all of them before failing for challenge in auth['challenges']: if challenge['status'] == 'invalid': error_details += ' CHALLENGE: {0}'.format(challenge['type']) if 'error' in challenge: error_details += ' DETAILS: {0};'.format(challenge['error']['detail']) else: error_details += ';' raise ModuleFailException("{0}: {1}".format(error.format(identifier_type + ':' + identifier), error_details))
def start_challenges(self): ''' Create new authorizations for all identifiers of the CSR, respectively start a new order for ACME v2. ''' self.authorizations = {} if self.version == 1: for identifier_type, identifier in self.identifiers: if identifier_type != 'dns': raise ModuleFailException('ACME v1 only supports DNS identifiers!') for identifier_type, identifier in self.identifiers: new_auth = self._new_authz_v1(identifier_type, identifier) self._add_or_update_auth(identifier_type, identifier, new_auth) else: self._new_order_v2() self.changed = True
def _validate_challenges(self, identifier_type, identifier, auth): ''' Validate the authorization provided in the auth dict. Returns True when the validation was successful and False when it was not. ''' for challenge in auth['challenges']: if self.challenge != challenge['type']: continue uri = challenge['uri'] if self.version == 1 else challenge['url'] challenge_response = {} if self.version == 1: token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) challenge_response["resource"] = "challenge" challenge_response["keyAuthorization"] = keyauthorization challenge_response["type"] = self.challenge result, info = self.account.send_signed_request( uri, challenge_response) if info['status'] not in [200, 202]: raise ModuleFailException( "Error validating challenge: CODE: {0} RESULT: {1}".format( info['status'], result)) status = '' while status not in ['valid', 'invalid', 'revoked']: result, dummy = self.account.get_request(auth['uri']) result['uri'] = auth['uri'] if self._add_or_update_auth(identifier_type, identifier, result): self.changed = True # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2 # "status (required, string): ... # If this field is missing, then the default value is "pending"." if self.version == 1 and 'status' not in result: status = 'pending' else: status = result['status'] time.sleep(2) if status == 'invalid': self._fail_challenge(identifier_type, identifier, result, 'Authorization for {0} returned invalid') return status == 'valid'
def main(): argument_spec = get_default_argspec() argument_spec.update(dict( private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), certificate=dict(type='path', required=True), revoke_reason=dict(type='int'), )) module = AnsibleModule( argument_spec=argument_spec, required_one_of=( ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'], ), supports_check_mode=False, ) handle_standard_module_arguments(module) try: account = ACMEAccount(module) # Load certificate certificate = pem_to_der(module.params.get('certificate')) certificate = nopad_b64(certificate) # Construct payload payload = { 'certificate': certificate } if module.params.get('revoke_reason') is not None: payload['reason'] = module.params.get('revoke_reason') # Determine endpoint if module.params.get('acme_version') == 1: endpoint = account.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = account.directory['revokeCert'] # Get hold of private key (if available) and make sure it comes from disk private_key = module.params.get('private_key_src') private_key_content = module.params.get('private_key_content') # Revoke certificate if private_key or private_key_content: # Step 1: load and parse private key error, private_key_data = account.parse_key(private_key, private_key_content) if error: raise ModuleFailException("error while parsing private key: %s" % error) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = account.send_signed_request(endpoint, payload, key_data=private_key_data, jws_header=jws_header) else: # Step 1: get hold of account URI created, account_data = account.setup_account(allow_creation=False) if created: raise AssertionError('Unwanted account creation') if account_data is None: raise ModuleFailException(msg='Account does not exist or is deactivated.') # Step 2: sign revokation request with account key result, info = account.send_signed_request(endpoint, payload) if info['status'] != 200: already_revoked = False # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6) if result.get('type') == 'urn:ietf:params:acme:error:alreadyRevoked': already_revoked = True else: # Hack for Boulder errors if module.params.get('acme_version') == 1: error_type = 'urn:acme:error:malformed' else: error_type = 'urn:ietf:params:acme:error:malformed' if result.get('type') == error_type and result.get('detail') == 'Certificate already revoked': # Fallback: boulder returns this in case the certificate was already revoked. already_revoked = True # If we know the certificate was already revoked, we don't fail, # but successfully terminate while indicating no change if already_revoked: module.exit_json(changed=False) raise ModuleFailException('Error revoking certificate: {0} {1}'.format(info['status'], result)) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)
def main(): argument_spec = get_default_argspec() argument_spec.update( dict( url=dict(type='str'), method=dict(type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(type='str'), fail_on_acme_error=dict(type='bool', default=True), )) module = AnsibleModule( argument_spec=argument_spec, mutually_exclusive=(['account_key_src', 'account_key_content'], ), required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], [ 'method', 'get', ['account_key_src', 'account_key_content'], True ], [ 'method', 'post', ['account_key_src', 'account_key_content'], True ], ), ) handle_standard_module_arguments(module) result = dict() changed = False try: # Get hold of ACMEAccount object (includes directory) account = ACMEAccount(module) method = module.params['method'] result['directory'] = account.directory.directory # Do we have to do more requests? if method != 'directory-only': url = module.params['url'] fail_on_acme_error = module.params['fail_on_acme_error'] # Do request if method == 'get': data, info = account.get_request(url, parse_json_result=False, fail_on_error=False) elif method == 'post': changed = True # only POSTs can change data, info = account.send_signed_request( url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False) # Update results result.update(dict( headers=info, output_text=to_native(data), )) # See if we can parse the result as JSON try: result['output_json'] = json.loads(data) except Exception as dummy: pass # Fail if error was returned if fail_on_acme_error and info['status'] >= 400: raise ModuleFailException( "ACME request failed: CODE: {0} RESULT: {1}".format( info['status'], data)) # Done! module.exit_json(changed=changed, **result) except ModuleFailException as e: e.do_fail(module, **result)
def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), account_uri=dict(required=False, type='str'), acme_directory=dict( required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), validate_certs=dict(required=False, default=True, type='bool'), private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), certificate=dict(required=True, type='path'), revoke_reason=dict(required=False, type='int'), select_crypto_backend=dict( required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), ), required_one_of=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), mutually_exclusive=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), supports_check_mode=False, ) set_crypto_backend(module) if not module.params.get('validate_certs'): module.warn( warning= 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.') try: account = ACMEAccount(module) # Load certificate certificate = pem_to_der(module.params.get('certificate')) certificate = nopad_b64(certificate) # Construct payload payload = {'certificate': certificate} if module.params.get('revoke_reason') is not None: payload['reason'] = module.params.get('revoke_reason') # Determine endpoint if module.params.get('acme_version') == 1: endpoint = account.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = account.directory['revokeCert'] # Get hold of private key (if available) and make sure it comes from disk private_key = module.params.get('private_key_src') private_key_content = module.params.get('private_key_content') # Revoke certificate if private_key or private_key_content: # Step 1: load and parse private key error, private_key_data = account.parse_key( private_key, private_key_content) if error: raise ModuleFailException( "error while parsing private key: %s" % error) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = account.send_signed_request( endpoint, payload, key_data=private_key_data, jws_header=jws_header) else: # Step 1: get hold of account URI changed = account.init_account( [], allow_creation=False, update_contact=False, ) if changed: raise AssertionError('Unwanted account change') # Step 2: sign revokation request with account key result, info = account.send_signed_request(endpoint, payload) if info['status'] != 200: already_revoked = False # Standarized error in draft 14 (https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-7.6) if result.get( 'type') == 'urn:ietf:params:acme:error:alreadyRevoked': already_revoked = True else: # Hack for Boulder errors if module.params.get('acme_version') == 1: error_type = 'urn:acme:error:malformed' else: error_type = 'urn:ietf:params:acme:error:malformed' if result.get('type') == error_type and result.get( 'detail') == 'Certificate already revoked': # Fallback: boulder returns this in case the certificate was already revoked. already_revoked = True # If we know the certificate was already revoked, we don't fail, # but successfully terminate while indicating no change if already_revoked: module.exit_json(changed=False) raise ModuleFailException( 'Error revoking certificate: {0} {1}'.format( info['status'], result)) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)
def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), account_uri=dict(required=False, type='str'), acme_directory=dict( required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), validate_certs=dict(required=False, default=True, type='bool'), terms_agreed=dict(required=False, default=False, type='bool'), state=dict(required=True, choices=['absent', 'present', 'changed_key'], type='str'), allow_creation=dict(required=False, default=True, type='bool'), contact=dict(required=False, type='list', elements='str', default=[]), new_account_key_src=dict(type='path'), new_account_key_content=dict(type='str', no_log=True), select_crypto_backend=dict( required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), ), required_one_of=(['account_key_src', 'account_key_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content'], ['new_account_key_src', 'new_account_key_content'], ), required_if=( # Make sure that for state == changed_key, one of # new_account_key_src and new_account_key_content are specified [ 'state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True ], ), supports_check_mode=True, ) set_crypto_backend(module) if not module.params.get('validate_certs'): module.warn( warning= 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.') if module.params.get('acme_version') < 2: module.fail_json( msg='The acme_account module requires the ACME v2 protocol!') try: account = ACMEAccount(module) changed = False state = module.params.get('state') diff_before = {} diff_after = {} if state == 'absent': created, account_data = account.setup_account(allow_creation=False) if account_data: diff_before = dict(account_data) diff_before['public_account_key'] = account.key_data['jwk'] if created: raise AssertionError('Unwanted account creation') if account_data is not None: # Account is not yet deactivated if not module.check_mode: # Deactivate it payload = {'status': 'deactivated'} result, info = account.send_signed_request( account.uri, payload) if info['status'] != 200: raise ModuleFailException( 'Error deactivating account: {0} {1}'.format( info['status'], result)) changed = True elif state == 'present': allow_creation = module.params.get('allow_creation') # Make sure contact is a list of strings (unfortunately, Ansible doesn't do that for us) contact = [str(v) for v in module.params.get('contact')] terms_agreed = module.params.get('terms_agreed') created, account_data = account.setup_account( contact, terms_agreed=terms_agreed, allow_creation=allow_creation, ) if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') if created: diff_before = {} else: diff_before = dict(account_data) diff_before['public_account_key'] = account.key_data['jwk'] updated = False if not created: updated, account_data = account.update_account( account_data, contact) changed = created or updated diff_after = dict(account_data) diff_after['public_account_key'] = account.key_data['jwk'] elif state == 'changed_key': # Parse new account key error, new_key_data = account.parse_key( module.params.get('new_account_key_src'), module.params.get('new_account_key_content')) if error: raise ModuleFailException( "error while parsing account key: %s" % error) # Verify that the account exists and has not been deactivated created, account_data = account.setup_account(allow_creation=False) if created: raise AssertionError('Unwanted account creation') if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') diff_before = dict(account_data) diff_before['public_account_key'] = account.key_data['jwk'] # Now we can start the account key rollover if not module.check_mode: # Compose inner signed message # https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-7.3.6 url = account.directory['keyChange'] protected = { "alg": new_key_data['alg'], "jwk": new_key_data['jwk'], "url": url, } payload = { "account": account.uri, "newKey": new_key_data['jwk'], # specified in draft 12 and older "oldKey": account.jwk, # specified in draft 13 and newer } data = account.sign_request(protected, payload, new_key_data) # Send request and verify result result, info = account.send_signed_request(url, data) if info['status'] != 200: raise ModuleFailException( 'Error account key rollover: {0} {1}'.format( info['status'], result)) if module._diff: account.key_data = new_key_data account.jws_header['alg'] = new_key_data['alg'] diff_after = account.get_account_data() elif module._diff: # Kind of fake diff_after diff_after = dict(diff_before) diff_after['public_account_key'] = new_key_data['jwk'] changed = True result = { 'changed': changed, 'account_uri': account.uri, } if module._diff: result['diff'] = { 'before': diff_before, 'after': diff_after, } module.exit_json(**result) except ModuleFailException as e: e.do_fail(module)
def main(): module = AnsibleModule( argument_spec=dict( challenge=dict(required=True, choices=['tls-alpn-01'], type='str'), challenge_data=dict(required=True, type='dict'), private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), ), required_one_of=(['private_key_src', 'private_key_content'], ), mutually_exclusive=(['private_key_src', 'private_key_content'], ), ) if not HAS_CRYPTOGRAPHY: module.fail(msg='cryptography >= 1.3 is required for this module.') try: # Get parameters challenge = module.params['challenge'] challenge_data = module.params['challenge_data'] # Get hold of private key private_key_content = module.params.get('private_key_content') if private_key_content is None: private_key_content = read_file(module.params['private_key_src']) else: private_key_content = to_bytes(private_key_content) try: private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key( private_key_content, password=None, backend=_cryptography_backend) except Exception as e: raise ModuleFailException( 'Error while loading private key: {0}'.format(e)) # Some common attributes domain = to_text(challenge_data['resource']) subject = issuer = cryptography.x509.Name([ cryptography.x509.NameAttribute( cryptography.x509.oid.NameOID.COMMON_NAME, domain), ]) not_valid_before = datetime.datetime.utcnow() not_valid_after = datetime.datetime.utcnow() + datetime.timedelta( days=10) # Generate regular self-signed certificate regular_certificate = cryptography.x509.CertificateBuilder( ).subject_name(subject).issuer_name(issuer).public_key( private_key.public_key()).serial_number( cryptography.x509.random_serial_number()).not_valid_before( not_valid_before).not_valid_after( not_valid_after).add_extension( cryptography.x509.SubjectAlternativeName( [cryptography.x509.DNSName(domain)]), critical=False, ).sign(private_key, cryptography.hazmat.primitives.hashes.SHA256(), _cryptography_backend) # Process challenge if challenge == 'tls-alpn-01': value = base64.b64decode(challenge_data['resource_value']) challenge_certificate = cryptography.x509.CertificateBuilder( ).subject_name(subject).issuer_name(issuer).public_key( private_key.public_key()).serial_number( cryptography.x509.random_serial_number()).not_valid_before( not_valid_before).not_valid_after( not_valid_after).add_extension( cryptography.x509.SubjectAlternativeName( [cryptography.x509.DNSName(domain)]), critical=False, ).add_extension( cryptography.x509.UnrecognizedExtension( cryptography.x509.ObjectIdentifier( "1.3.6.1.5.5.7.1.31"), encode_octet_string(value), ), critical=True, ).sign( private_key, cryptography.hazmat.primitives.hashes.SHA256(), _cryptography_backend) module.exit_json( changed=True, domain=domain, challenge_certificate=challenge_certificate.public_bytes( cryptography.hazmat.primitives.serialization.Encoding.PEM), regular_certificate=regular_certificate.public_bytes( cryptography.hazmat.primitives.serialization.Encoding.PEM)) except ModuleFailException as e: e.do_fail(module)
def encode_octet_string(octet_string): if len(octet_string) >= 128: raise ModuleFailException( 'Cannot handle octet strings with more than 128 bytes') return b'\x04' + chr(len(octet_string)) + octet_string
def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), acme_directory=dict( required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), validate_certs=dict(required=False, default=True, type='bool'), private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), certificate=dict(required=True, type='path'), revoke_reason=dict(required=False, type='int'), ), required_one_of=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), mutually_exclusive=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), supports_check_mode=False, ) if not module.params.get('validate_certs'): module.warn( warning= 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.') try: account = ACMEAccount(module) # Load certificate certificate_lines = [] try: with open(module.params.get('certificate'), "rt") as f: header_line_count = 0 for line in f: if line.startswith('-----'): header_line_count += 1 if header_line_count == 2: # If certificate file contains other certs appended # (like intermediate certificates), ignore these. break continue certificate_lines.append(line.strip()) except Exception as err: raise ModuleFailException("cannot load certificate file: %s" % to_native(err), exception=traceback.format_exc()) certificate = nopad_b64(base64.b64decode(''.join(certificate_lines))) # Construct payload payload = {'certificate': certificate} if module.params.get('revoke_reason') is not None: payload['reason'] = module.params.get('revoke_reason') # Determine endpoint if module.params.get('acme_version') == 1: endpoint = account.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = account.directory['revokeCert'] # Get hold of private key (if available) and make sure it comes from disk private_key = module.params.get('private_key_src') if module.params.get('private_key_content') is not None: fd, tmpsrc = tempfile.mkstemp() module.add_cleanup_file( tmpsrc) # Ansible will delete the file on exit f = os.fdopen(fd, 'wb') try: f.write( module.params.get('private_key_content').encode('utf-8')) private_key = tmpsrc except Exception as err: try: f.close() except Exception as e: pass raise ModuleFailException( "failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) f.close() # Revoke certificate if private_key: # Step 1: load and parse private key error, private_key_data = account.parse_account_key(private_key) if error: raise ModuleFailException( "error while parsing private key: %s" % error) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = account.send_signed_request( endpoint, payload, key=private_key, key_data=private_key_data, jws_header=jws_header) else: # Step 1: get hold of account URI changed = account.init_account( [], allow_creation=False, update_contact=False, ) if changed: raise AssertionError('Unwanted account change') # Step 2: sign revokation request with account key result, info = account.send_signed_request(endpoint, payload) if info['status'] != 200: if module.params.get('acme_version') == 1: error_type = 'urn:acme:error:malformed' else: error_type = 'urn:ietf:params:acme:error:malformed' if result.get('type') == error_type and result.get( 'detail') == 'Certificate already revoked': # Fallback: boulder returns this in case the certificate was already revoked. module.exit_json(changed=False) raise ModuleFailException( 'Error revoking certificate: {0} {1}'.format( info['status'], result)) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)
def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), account_uri=dict(required=False, type='str'), acme_directory=dict( required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), validate_certs=dict(required=False, default=True, type='bool'), url=dict(required=False, type='str'), method=dict(required=False, type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(required=False, type='str'), fail_on_acme_error=dict(required=False, type='bool', default=True), select_crypto_backend=dict( required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), ), mutually_exclusive=(['account_key_src', 'account_key_content'], ), required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], [ 'method', 'get', ['account_key_src', 'account_key_content'], True ], [ 'method', 'post', ['account_key_src', 'account_key_content'], True ], ), ) set_crypto_backend(module) if not module.params.get('validate_certs'): module.warn( warning= 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.') result = dict() changed = False try: # Get hold of ACMEAccount object (includes directory) account = ACMEAccount(module) method = module.params['method'] result['directory'] = account.directory.directory # Do we have to do more requests? if method != 'directory-only': url = module.params['url'] fail_on_acme_error = module.params['fail_on_acme_error'] # Do request if method == 'get': data, info = account.get_request(url, parse_json_result=False, fail_on_error=False) elif method == 'post': changed = True # only POSTs can change data, info = account.send_signed_request( url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False) # Update results result.update(dict( headers=info, output_text=to_native(data), )) # See if we can parse the result as JSON try: result['output_json'] = json.loads(data) except Exception as dummy: pass # Fail if error was returned if fail_on_acme_error and info['status'] >= 400: raise ModuleFailException( "ACME request failed: CODE: {0} RESULT: {1}".format( info['status'], data)) # Done! module.exit_json(changed=changed, **result) except ModuleFailException as e: e.do_fail(module, **result)
def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), acme_directory=dict( required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), validate_certs=dict(required=False, default=True, type='bool'), terms_agreed=dict(required=False, default=False, type='bool'), state=dict(required=True, choices=['absent', 'present', 'changed_key'], type='str'), allow_creation=dict(required=False, default=True, type='bool'), contact=dict(required=False, type='list', default=[]), new_account_key_src=dict(type='path'), new_account_key_content=dict(type='str', no_log=True), select_crypto_backend=dict( required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), ), required_one_of=(['account_key_src', 'account_key_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content'], ['new_account_key_src', 'new_account_key_content'], ), required_if=( # Make sure that for state == changed_key, one of # new_account_key_src and new_account_key_content are specified [ 'state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True ], ), supports_check_mode=True, ) set_crypto_backend(module) if not module.params.get('validate_certs'): module.warn( warning= 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.') if module.params.get('acme_version') < 2: module.fail_json( msg='The acme_account module requires the ACME v2 protocol!') try: account = ACMEAccount(module) state = module.params.get('state') if state == 'absent': changed = account.init_account( [], allow_creation=False, update_contact=False, ) if changed: raise AssertionError('Unwanted account change') if account.uri is not None: # Account does exist account_data = account.get_account_data() if account_data is not None: # Account is not yet deactivated if not module.check_mode: # Deactivate it payload = {'status': 'deactivated'} result, info = account.send_signed_request( account.uri, payload) if info['status'] != 200: raise ModuleFailException( 'Error deactivating account: {0} {1}'.format( info['status'], result)) module.exit_json(changed=True, account_uri=account.uri) module.exit_json(changed=False, account_uri=account.uri) elif state == 'present': allow_creation = module.params.get('allow_creation') contact = module.params.get('contact') terms_agreed = module.params.get('terms_agreed') changed = account.init_account( contact, terms_agreed=terms_agreed, allow_creation=allow_creation, ) if account.uri is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') module.exit_json(changed=changed, account_uri=account.uri) elif state == 'changed_key': # Parse new account key error, new_key_data = account.parse_key( module.params.get('new_account_key_src'), module.params.get('new_account_key_content')) if error: raise ModuleFailException( "error while parsing account key: %s" % error) # Verify that the account exists and has not been deactivated changed = account.init_account( [], allow_creation=False, update_contact=False, ) if changed: raise AssertionError('Unwanted account change') if account.uri is None or account.get_account_data() is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') # Now we can start the account key rollover if not module.check_mode: # Compose inner signed message # https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.3.6 url = account.directory['keyChange'] protected = { "alg": new_key_data['alg'], "jwk": new_key_data['jwk'], "url": url, } payload = { "account": account.uri, "newKey": new_key_data['jwk'], # specified in draft 12 "oldKey": account. jwk, # discussed in https://github.com/ietf-wg-acme/acme/pull/425, # might be required in draft 13 } data = account.sign_request(protected, payload, new_key_data) # Send request and verify result result, info = account.send_signed_request(url, data) if info['status'] != 200: raise ModuleFailException( 'Error account key rollover: {0} {1}'.format( info['status'], result)) module.exit_json(changed=True, account_uri=account.uri) except ModuleFailException as e: e.do_fail(module)
def main(): argument_spec = get_default_argspec() argument_spec.update( dict( terms_agreed=dict(type='bool', default=False), state=dict(type='str', required=True, choices=['absent', 'present', 'changed_key']), allow_creation=dict(type='bool', default=True), contact=dict(type='list', elements='str', default=[]), new_account_key_src=dict(type='path'), new_account_key_content=dict(type='str', no_log=True), )) module = AnsibleModule( argument_spec=argument_spec, required_one_of=(['account_key_src', 'account_key_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content'], ['new_account_key_src', 'new_account_key_content'], ), required_if=( # Make sure that for state == changed_key, one of # new_account_key_src and new_account_key_content are specified [ 'state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True ], ), supports_check_mode=True, ) handle_standard_module_arguments(module, needs_acme_v2=True) try: account = ACMEAccount(module) changed = False state = module.params.get('state') diff_before = {} diff_after = {} if state == 'absent': created, account_data = account.setup_account(allow_creation=False) if account_data: diff_before = dict(account_data) diff_before['public_account_key'] = account.key_data['jwk'] if created: raise AssertionError('Unwanted account creation') if account_data is not None: # Account is not yet deactivated if not module.check_mode: # Deactivate it payload = {'status': 'deactivated'} result, info = account.send_signed_request( account.uri, payload) if info['status'] != 200: raise ModuleFailException( 'Error deactivating account: {0} {1}'.format( info['status'], result)) changed = True elif state == 'present': allow_creation = module.params.get('allow_creation') # Make sure contact is a list of strings (unfortunately, Ansible doesn't do that for us) contact = [str(v) for v in module.params.get('contact')] terms_agreed = module.params.get('terms_agreed') created, account_data = account.setup_account( contact, terms_agreed=terms_agreed, allow_creation=allow_creation, ) if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') if created: diff_before = {} else: diff_before = dict(account_data) diff_before['public_account_key'] = account.key_data['jwk'] updated = False if not created: updated, account_data = account.update_account( account_data, contact) changed = created or updated diff_after = dict(account_data) diff_after['public_account_key'] = account.key_data['jwk'] elif state == 'changed_key': # Parse new account key error, new_key_data = account.parse_key( module.params.get('new_account_key_src'), module.params.get('new_account_key_content')) if error: raise ModuleFailException( "error while parsing account key: %s" % error) # Verify that the account exists and has not been deactivated created, account_data = account.setup_account(allow_creation=False) if created: raise AssertionError('Unwanted account creation') if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') diff_before = dict(account_data) diff_before['public_account_key'] = account.key_data['jwk'] # Now we can start the account key rollover if not module.check_mode: # Compose inner signed message # https://tools.ietf.org/html/rfc8555#section-7.3.5 url = account.directory['keyChange'] protected = { "alg": new_key_data['alg'], "jwk": new_key_data['jwk'], "url": url, } payload = { "account": account.uri, "newKey": new_key_data['jwk'], # specified in draft 12 and older "oldKey": account.jwk, # specified in draft 13 and newer } data = account.sign_request(protected, payload, new_key_data) # Send request and verify result result, info = account.send_signed_request(url, data) if info['status'] != 200: raise ModuleFailException( 'Error account key rollover: {0} {1}'.format( info['status'], result)) if module._diff: account.key_data = new_key_data account.jws_header['alg'] = new_key_data['alg'] diff_after = account.get_account_data() elif module._diff: # Kind of fake diff_after diff_after = dict(diff_before) diff_after['public_account_key'] = new_key_data['jwk'] changed = True result = { 'changed': changed, 'account_uri': account.uri, } if module._diff: result['diff'] = { 'before': diff_before, 'after': diff_after, } module.exit_json(**result) except ModuleFailException as e: e.do_fail(module)