def __init__(self, module, backend): self.module = module self.version = module.params['acme_version'] self.challenge = module.params['challenge'] self.csr = module.params['csr'] self.csr_content = module.params['csr_content'] self.dest = module.params.get('dest') self.fullchain_dest = module.params.get('fullchain_dest') self.chain_dest = module.params.get('chain_dest') self.client = ACMEClient(module, backend) self.account = ACMEAccount(self.client) self.directory = self.client.directory self.data = module.params['data'] self.authorizations = None self.cert_days = -1 self.order = None self.order_uri = self.data.get('order_uri') if self.data else None self.all_chains = None self.select_chain_matcher = [] if self.module.params['select_chain']: for criterium_idx, criterium in enumerate(self.module.params['select_chain']): self.select_chain_matcher.append( self.client.backend.create_chain_matcher( Criterium(criterium, index=criterium_idx))) # 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 self.csr is not None and not os.path.exists(self.csr): raise ModuleFailException("CSR %s not found" % (self.csr)) # Extract list of identifiers from CSR self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
def __init__(self, module): module.deprecate( 'Please adjust your custom module/plugin to the ACME module_utils refactor ' '(https://github.com/ansible-collections/community.crypto/pull/184). The ' 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' 'your code', version='2.0.0', collection_name='community.crypto') backend = get_compatibility_backend(module) self.client = ACMEClient(module, backend) self.account = ACMEAccount(self.client) self.key = self.client.account_key_file self.key_content = self.client.account_key_content self.uri = self.client.account_uri self.key_data = self.client.account_key_data self.jwk = self.client.account_jwk self.jws_header = self.client.account_jws_header self.directory = self.client.directory
def main(): argument_spec = get_default_argspec() argument_spec.update( dict(retrieve_orders=dict( type='str', default='ignore', choices=['ignore', 'url_list', 'object_list']), )) module = AnsibleModule( argument_spec=argument_spec, required_one_of=(['account_key_src', 'account_key_content'], ), mutually_exclusive=(['account_key_src', 'account_key_content'], ), supports_check_mode=True, ) if module._name in ('acme_account_facts', 'community.crypto.acme_account_facts'): module.deprecate( "The 'acme_account_facts' module has been renamed to 'acme_account_info'", version='2.0.0', collection_name='community.crypto') backend = create_backend(module, True) try: client = ACMEClient(module, backend) account = ACMEAccount(client) # Check whether account exists created, account_data = account.setup_account( [], allow_creation=False, remove_account_uri_if_not_exists=True, ) if created: raise AssertionError('Unwanted account creation') result = { 'changed': False, 'exists': client.account_uri is not None, 'account_uri': client.account_uri, } if client.account_uri is not None: # Make sure promised data is there if 'contact' not in account_data: account_data['contact'] = [] account_data['public_account_key'] = client.account_key_data['jwk'] result['account'] = account_data # Retrieve orders list if account_data.get( 'orders') and module.params['retrieve_orders'] != 'ignore': orders = get_orders_list(module, client, account_data['orders']) result['order_uris'] = orders if module.params['retrieve_orders'] == 'url_list': module.deprecate( 'retrieve_orders=url_list now returns the order URI list as `order_uris`.' ' Right now it also returns this list as `orders` for backwards compatibility,' ' but this will stop in community.crypto 2.0.0', version='2.0.0', collection_name='community.crypto') result['orders'] = orders if module.params['retrieve_orders'] == 'object_list': result['orders'] = [ get_order(client, order) for order in orders ] module.exit_json(**result) except ModuleFailException as e: e.do_fail(module)
class ACMECertificateClient(object): ''' ACME client class. Uses an ACME account object and a CSR to start and validate ACME challenges and download the respective certificates. ''' def __init__(self, module, backend): self.module = module self.version = module.params['acme_version'] self.challenge = module.params['challenge'] self.csr = module.params['csr'] self.csr_content = module.params['csr_content'] self.dest = module.params.get('dest') self.fullchain_dest = module.params.get('fullchain_dest') self.chain_dest = module.params.get('chain_dest') self.client = ACMEClient(module, backend) self.account = ACMEAccount(self.client) self.directory = self.client.directory self.data = module.params['data'] self.authorizations = None self.cert_days = -1 self.order = None self.order_uri = self.data.get('order_uri') if self.data else None self.all_chains = None self.select_chain_matcher = [] if self.module.params['select_chain']: for criterium_idx, criterium in enumerate( self.module.params['select_chain']): self.select_chain_matcher.append( self.client.backend.create_chain_matcher( Criterium(criterium, index=criterium_idx))) # 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 self.csr is not None and not os.path.exists(self.csr): raise ModuleFailException("CSR %s not found" % (self.csr)) # Extract list of identifiers from CSR self.identifiers = self.client.backend.get_csr_identifiers( csr_filename=self.csr, csr_content=self.csr_content) def is_first_step(self): ''' Return True if this is the first execution of this module, i.e. if a sufficient data object from a first run has not been provided. ''' if self.data is None: return True if self.version == 1: # As soon as self.data is a non-empty object, we are in the second stage. return not self.data else: # We are in the second stage if data.order_uri is given (which has been # stored in self.order_uri by the constructor). return self.order_uri is None 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: authz = Authorization.create(self.client, identifier_type, identifier) self.authorizations[authz.combined_identifier] = authz else: self.order = Order.create(self.client, self.identifiers) self.order_uri = self.order.url self.order.load_authorizations(self.client) self.authorizations.update(self.order.authorizations) self.changed = True def get_challenges_data(self, first_step): ''' Get challenge details for the chosen challenge type. Return a tuple of generic challenge details, and specialized DNS challenge details. ''' # Get general challenge data data = {} for type_identifier, authz in self.authorizations.items(): identifier_type, identifier = split_identifier(type_identifier) # Skip valid authentications: their challenges are already valid # and do not need to be returned if authz.status == 'valid': continue # We drop the type from the key to preserve backwards compatibility data[identifier] = authz.get_challenge_data(self.client) if first_step and self.challenge not in data[identifier]: raise ModuleFailException( "Found no challenge of type '{0}' for identifier {1}!". format(self.challenge, type_identifier)) # Get DNS challenge data data_dns = {} if self.challenge == 'dns-01': for identifier, challenges in data.items(): if self.challenge in challenges: values = data_dns.get(challenges[self.challenge]['record']) if values is None: values = [] data_dns[challenges[self.challenge]['record']] = values values.append(challenges[self.challenge]['resource_value']) return data, data_dns 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: authz = Authorization.create(self.client, identifier_type, identifier) self.authorizations[combine_identifier(identifier_type, identifier)] = authz else: # For ACME v2, we obtain the order object by fetching the # order URI, and extract the information from there. self.order = Order.from_url(self.client, self.order_uri) self.order.load_authorizations(self.client) self.authorizations.update(self.order.authorizations) # Step 2: validate pending challenges for type_identifier, authz in self.authorizations.items(): if authz.status == 'pending': identifier_type, identifier = split_identifier(type_identifier) authz.call_validate(self.client, self.challenge) self.changed = True def download_alternate_chains(self, cert): alternate_chains = [] for alternate in cert.alternates: try: alt_cert = CertificateChain.download(self.client, alternate) except ModuleFailException as e: self.module.warn( 'Error while downloading alternative certificate {0}: {1}'. format(alternate, e)) continue alternate_chains.append(alt_cert) return alternate_chains def find_matching_chain(self, chains): for criterium_idx, matcher in enumerate(self.select_chain_matcher): for chain in chains: if matcher.match(chain): self.module.debug( 'Found matching chain for criterium {0}'.format( criterium_idx)) return chain return None 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: authz = self.authorizations.get( combine_identifier(identifier_type, identifier)) if authz is None: raise ModuleFailException( 'Found no authorization information for "{identifier}"!'. format(identifier=combine_identifier( identifier_type, identifier))) if authz.status != 'valid': authz.raise_error( 'Status is "{status}" and not "valid"'.format( status=authz.status)) if self.version == 1: cert = retrieve_acme_v1_certificate( self.client, pem_to_der(self.csr, self.csr_content)) else: self.order.finalize(self.client, pem_to_der(self.csr, self.csr_content)) cert = CertificateChain.download(self.client, self.order.certificate_uri) if self.module.params[ 'retrieve_all_alternates'] or self.select_chain_matcher: # Retrieve alternate chains alternate_chains = self.download_alternate_chains(cert) # Prepare return value for all alternate chains if self.module.params['retrieve_all_alternates']: self.all_chains = [cert.to_json()] for alt_chain in alternate_chains: self.all_chains.append(alt_chain.to_json()) # Try to select alternate chain depending on criteria if self.select_chain_matcher: matching_chain = self.find_matching_chain([cert] + alternate_chains) if matching_chain: cert = matching_chain else: self.module.debug( 'Found no matching alternative chain') if cert.cert is not None: pem_cert = cert.cert chain = cert.chain if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')): self.cert_days = self.client.backend.get_cert_days(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 = self.client.backend.get_cert_days( 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 deactivate_authzs(self): ''' Deactivates all valid authz's. Does not raise exceptions. https://community.letsencrypt.org/t/authorization-deactivation/19860/2 https://tools.ietf.org/html/rfc8555#section-7.5.2 ''' for authz in self.authorizations.values(): try: authz.deactivate(self.client) except Exception: # ignore errors pass if authz.status != 'deactivated': self.module.warn( warning='Could not deactivate authz object {0}.'.format( authz.url))
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), private_key_passphrase=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, ) backend = create_backend(module, False) try: client = ACMEClient(module, backend) account = ACMEAccount(client) # 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 = client.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = client.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: passphrase = module.params['private_key_passphrase'] # Step 1: load and parse private key try: private_key_data = client.parse_key(private_key, private_key_content, passphrase=passphrase) except KeyParsingError as e: raise ModuleFailException( "Error while parsing private key: {msg}".format(msg=e.msg)) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = client.send_signed_request( endpoint, payload, key_data=private_key_data, jws_header=jws_header, fail_on_error=False) 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 = client.send_signed_request(endpoint, payload, fail_on_error=False) 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 ACMEProtocolException('Failed to revoke certificate', info=info, content_json=result) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)
class ACMELegacyAccount(object): ''' ACME account object. Handles the authorized communication with the ACME server. Provides access to account bound information like the currently active authorizations and valid certificates ''' def __init__(self, module): module.deprecate( 'Please adjust your custom module/plugin to the ACME module_utils refactor ' '(https://github.com/ansible-collections/community.crypto/pull/184). The ' 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' 'your code', version='2.0.0', collection_name='community.crypto') backend = get_compatibility_backend(module) self.client = ACMEClient(module, backend) self.account = ACMEAccount(self.client) self.key = self.client.account_key_file self.key_content = self.client.account_key_content self.uri = self.client.account_uri self.key_data = self.client.account_key_data self.jwk = self.client.account_jwk self.jws_header = self.client.account_jws_header self.directory = self.client.directory def get_keyauthorization(self, token): ''' Returns the key authorization for the given token https://tools.ietf.org/html/rfc8555#section-8.1 ''' return create_key_authorization(self.client, token) def parse_key(self, key_file=None, key_content=None): ''' Parses an RSA or Elliptic Curve key file in PEM format and returns a pair (error, key_data). ''' try: return None, self.client.parse_key(key_file=key_file, key_content=key_content) except KeyParsingError as e: return e.msg, {} def sign_request(self, protected, payload, key_data, encode_payload=True): return self.client.sign_request(protected, payload, key_data, encode_payload=encode_payload) def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True): ''' Sends a JWS signed HTTP POST request to the ACME server and returns the response as dictionary https://tools.ietf.org/html/rfc8555#section-6.2 If payload is None, a POST-as-GET is performed. (https://tools.ietf.org/html/rfc8555#section-6.3) ''' return self.client.send_signed_request( url, payload, key_data=key_data, jws_header=jws_header, parse_json_result=parse_json_result, encode_payload=encode_payload, fail_on_error=False, ) def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True): ''' Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback to GET if server replies with a status code of 405. ''' return self.client.get_request( uri, parse_json_result=parse_json_result, headers=headers, get_only=get_only, fail_on_error=fail_on_error, ) def set_account_uri(self, uri): ''' Set account URI. For ACME v2, it needs to be used to sending signed requests. ''' self.client.set_account_uri(uri) self.uri = self.client.account_uri def get_account_data(self): ''' Retrieve account information. Can only be called when the account URI is already known (such as after calling setup_account). Return None if the account was deactivated, or a dict otherwise. ''' return self.account.get_account_data() def setup_account(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True, remove_account_uri_if_not_exists=False, external_account_binding=None): ''' Detect or create an account on the ACME server. For ACME v1, as the only way (without knowing an account URI) to test if an account exists is to try and create one with the provided account key, this method will always result in an account being present (except on error situations). For ACME v2, a new account will only be created if ``allow_creation`` is set to True. For ACME v2, ``check_mode`` is fully respected. For ACME v1, the account might be created if it does not yet exist. Return a pair ``(created, account_data)``. Here, ``created`` will be ``True`` in case the account was created or would be created (check mode). ``account_data`` will be the current account data, or ``None`` if the account does not exist. The account URI will be stored in ``self.uri``; if it is ``None``, the account does not exist. If specified, ``external_account_binding`` should be a dictionary with keys ``kid``, ``alg`` and ``key`` (https://tools.ietf.org/html/rfc8555#section-7.3.4). https://tools.ietf.org/html/rfc8555#section-7.3 ''' result = self.account.setup_account( contact=contact, agreement=agreement, terms_agreed=terms_agreed, allow_creation=allow_creation, remove_account_uri_if_not_exists=remove_account_uri_if_not_exists, external_account_binding=external_account_binding, ) self.uri = self.client.account_uri return result def update_account(self, account_data, contact=None): ''' Update an account on the ACME server. Check mode is fully respected. The current account data must be provided as ``account_data``. Return a pair ``(updated, account_data)``, where ``updated`` is ``True`` in case something changed (contact info updated) or would be changed (check mode), and ``account_data`` the updated account data. https://tools.ietf.org/html/rfc8555#section-7.3.2 ''' return self.account.update_account(account_data, contact=contact)
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), new_account_key_passphrase=dict(type='str', no_log=True), external_account_binding=dict(type='dict', options=dict( kid=dict(type='str', required=True), alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']), key=dict(type='str', required=True, 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, ) backend = create_backend(module, True) if module.params['external_account_binding']: # Make sure padding is there key = module.params['external_account_binding']['key'] if len(key) % 4 != 0: key = key + ('=' * (4 - (len(key) % 4))) # Make sure key is Base64 encoded try: base64.urlsafe_b64decode(key) except Exception as e: module.fail_json(msg='Key for external_account_binding must be Base64 URL encoded (%s)' % e) module.params['external_account_binding']['key'] = key try: client = ACMEClient(module, backend) account = ACMEAccount(client) 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'] = client.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 = client.send_signed_request( client.account_uri, payload, error_msg='Failed to deactivate account', expected_status_codes=[200]) changed = True elif state == 'present': allow_creation = module.params.get('allow_creation') contact = [str(v) for v in module.params.get('contact')] terms_agreed = module.params.get('terms_agreed') external_account_binding = module.params.get('external_account_binding') created, account_data = account.setup_account( contact, terms_agreed=terms_agreed, allow_creation=allow_creation, external_account_binding=external_account_binding, ) 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'] = client.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'] = client.account_key_data['jwk'] elif state == 'changed_key': # Parse new account key try: new_key_data = client.parse_key( module.params.get('new_account_key_src'), module.params.get('new_account_key_content'), passphrase=module.params.get('new_account_key_passphrase'), ) except KeyParsingError as e: raise ModuleFailException("Error while parsing new account key: {msg}".format(msg=e.msg)) # 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'] = client.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 = client.directory['keyChange'] protected = { "alg": new_key_data['alg'], "jwk": new_key_data['jwk'], "url": url, } payload = { "account": client.account_uri, "newKey": new_key_data['jwk'], # specified in draft 12 and older "oldKey": client.account_jwk, # specified in draft 13 and newer } data = client.sign_request(protected, payload, new_key_data) # Send request and verify result result, info = client.send_signed_request( url, data, error_msg='Failed to rollover account key', expected_status_codes=[200]) if module._diff: client.account_key_data = new_key_data client.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': client.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)