def download(cls, client, url): content, info = client.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}, as content type is not application/pem-certificate-chain: {1} (headers: {2})" .format(url, content, info)) result = cls(url) # Parse data certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True) if certs: result.cert = certs[0] result.chain = certs[1:] process_links( info, lambda link, relation: result._process_links( client, link, relation)) if result.cert is None: raise ModuleFailException( "Failed to parse certificate chain download from {0}: {1} (headers: {2})" .format(url, content, info)) return result
def pem_to_der(pem_filename=None, pem_content=None): ''' Load PEM file, or use PEM file's content, and convert to DER. If PEM contains multiple entities, the first entity will be used. ''' certificate_lines = [] if pem_content is not None: lines = pem_content.splitlines() elif pem_filename is not None: try: with open(pem_filename, "rt") as f: lines = list(f) except Exception as err: raise ModuleFailException("cannot load PEM file {0}: {1}".format( pem_filename, to_native(err)), exception=traceback.format_exc()) else: raise ModuleFailException( 'One of pem_filename and pem_content must be provided') header_line_count = 0 for line in lines: 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()) return base64.b64decode(''.join(certificate_lines))
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 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 split_identifier(identifier): parts = identifier.split(':', 1) if len(parts) != 2: raise ModuleFailException( 'Identifier "{identifier}" is not of the form <type>:<identifier>'. format(identifier=identifier)) return parts
def read_file(fn, mode='b'): try: with open(fn, 'r' + mode) as f: return f.read() except Exception as e: raise ModuleFailException('Error while reading file "{0}": {1}'.format( fn, e))
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 ``client.account_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 ''' if self.client.account_uri is not None: created = False # Verify that the account key belongs to the URI. # (If update_contact is True, this will be done below.) account_data = self.get_account_data() if account_data is None: if remove_account_uri_if_not_exists and not allow_creation: self.client.account_uri = None else: raise ModuleFailException( "Account is deactivated or does not exist!") else: created, account_data = self._new_reg( contact, agreement=agreement, terms_agreed=terms_agreed, allow_creation=allow_creation and not self.client.module.check_mode, external_account_binding=external_account_binding, ) if self.client.module.check_mode and self.client.account_uri is None and allow_creation: created = True account_data = {'contact': contact or []} return created, account_data
def write_file(module, dest, content): ''' Write content to destination file dest, only if the content has changed. ''' changed = False # create a tempfile fd, tmpsrc = tempfile.mkstemp(text=False) f = os.fdopen(fd, 'wb') try: f.write(content) except Exception as err: try: f.close() except Exception as dummy: pass os.remove(tmpsrc) raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) f.close() checksum_src = None checksum_dest = None # raise an error if there is no tmpsrc file if not os.path.exists(tmpsrc): try: os.remove(tmpsrc) except Exception as dummy: pass raise ModuleFailException("Source %s does not exist" % (tmpsrc)) if not os.access(tmpsrc, os.R_OK): os.remove(tmpsrc) raise ModuleFailException("Source %s not readable" % (tmpsrc)) checksum_src = module.sha1(tmpsrc) # check if there is no dest file if os.path.exists(dest): # raise an error if copy has no permission on dest if not os.access(dest, os.W_OK): os.remove(tmpsrc) raise ModuleFailException("Destination %s not writable" % (dest)) if not os.access(dest, os.R_OK): os.remove(tmpsrc) raise ModuleFailException("Destination %s not readable" % (dest)) checksum_dest = module.sha1(dest) else: dirname = os.path.dirname(dest) or '.' if not os.access(dirname, os.W_OK): os.remove(tmpsrc) raise ModuleFailException("Destination dir %s not writable" % (dirname)) if checksum_src != checksum_dest: try: shutil.copyfile(tmpsrc, dest) changed = True except Exception as err: os.remove(tmpsrc) raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc()) os.remove(tmpsrc) return changed
def __init__(self, module, account): self.module = module self.directory_root = module.params['acme_directory'] self.version = module.params['acme_version'] self.directory, dummy = account.get_request(self.directory_root, get_only=True) # Check whether self.version matches what we expect if self.version == 1: for key in ('new-reg', 'new-authz', 'new-cert'): if key not in self.directory: raise ModuleFailException( "ACME directory does not seem to follow protocol ACME v1" ) if self.version == 2: for key in ('newNonce', 'newAccount', 'newOrder'): if key not in self.directory: raise ModuleFailException( "ACME directory does not seem to follow protocol ACME v2" )
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), module=self.module) 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 call_validate(self, client, challenge_type, wait=True): ''' Validate the authorization provided in the auth dict. Returns True when the validation was successful and False when it was not. ''' challenge = self.find_challenge(challenge_type) if challenge is None: raise ModuleFailException('Found no challenge of type "{challenge}" for identifier {identifier}!'.format( challenge=challenge_type, identifier=self.combined_identifier, )) challenge.call_validate(client) if not wait: return self.status == 'valid' return self.wait_for_validation(client, challenge_type)
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 sign_request(self, protected, payload, key_data, encode_payload=True): ''' Signs an ACME request. ''' try: if payload is None: # POST-as-GET payload64 = '' else: # POST if encode_payload: payload = self.module.jsonify(payload).encode('utf8') payload64 = nopad_b64(to_bytes(payload)) protected64 = nopad_b64( self.module.jsonify(protected).encode('utf8')) except Exception as e: raise ModuleFailException( "Failed to encode payload / headers as JSON: {0}".format(e)) return self.backend.sign(payload64, protected64, key_data)
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. ''' if self.client.account_uri is None: raise ModuleFailException("Account URI unknown") if self.client.version == 1: data = {} data['resource'] = 'reg' result, info = self.client.send_signed_request( self.client.account_uri, data, fail_on_error=False) else: # try POST-as-GET first (draft-15 or newer) data = None result, info = self.client.send_signed_request( self.client.account_uri, data, fail_on_error=False) # check whether that failed with a malformed request error if info['status'] >= 400 and result.get( 'type') == 'urn:ietf:params:acme:error:malformed': # retry as a regular POST (with no changed data) for pre-draft-15 ACME servers data = {} result, info = self.client.send_signed_request( self.client.account_uri, data, fail_on_error=False) if info['status'] in (400, 403) and result.get( 'type') == 'urn:ietf:params:acme:error:unauthorized': # Returned when account is deactivated return None if info['status'] in (400, 404) and result.get( 'type') == 'urn:ietf:params:acme:error:accountDoesNotExist': # Returned when account does not exist return None if info['status'] < 200 or info['status'] >= 300: raise ACMEProtocolException(self.client.module, msg='Error retrieving account data', info=info, content_json=result) return result
def __init__(self, module, backend): # Set to true to enable logging of all signed requests self._debug = False self.module = module self.backend = backend self.version = module.params['acme_version'] # account_key path and content are mutually exclusive self.account_key_file = module.params['account_key_src'] self.account_key_content = module.params['account_key_content'] self.account_key_passphrase = module.params['account_key_passphrase'] # Grab account URI from module parameters. # Make sure empty string is treated as None. self.account_uri = module.params.get('account_uri') or None self.account_key_data = None self.account_jwk = None self.account_jws_header = None if self.account_key_file is not None or self.account_key_content is not None: try: self.account_key_data = self.parse_key( key_file=self.account_key_file, key_content=self.account_key_content, passphrase=self.account_key_passphrase) except KeyParsingError as e: raise ModuleFailException( "Error while parsing account key: {msg}".format(msg=e.msg)) self.account_jwk = self.account_key_data['jwk'] self.account_jws_header = { "alg": self.account_key_data['alg'], "jwk": self.account_jwk, } if self.account_uri: # Make sure self.account_jws_header is updated self.set_account_uri(self.account_uri) self.directory = ACMEDirectory(module, self)
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)
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( challenge=dict(type='str', required=True, choices=['tls-alpn-01']), challenge_data=dict(type='dict', required=True), private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), private_key_passphrase=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_json(msg=missing_required_lib('cryptography >= 1.3'), exception=CRYPTOGRAPHY_IMP_ERR) 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') private_key_passphrase = module.params.get('private_key_passphrase') 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=to_bytes(private_key_passphrase) if private_key_passphrase is not None else 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']) identifier_type, identifier = to_text( challenge_data.get('resource_original', 'dns:' + challenge_data['resource'])).split( ':', 1) subject = issuer = cryptography.x509.Name([]) not_valid_before = datetime.datetime.utcnow() not_valid_after = datetime.datetime.utcnow() + datetime.timedelta( days=10) if identifier_type == 'dns': san = cryptography.x509.DNSName(identifier) elif identifier_type == 'ip': san = cryptography.x509.IPAddress(ipaddress.ip_address(identifier)) else: raise ModuleFailException( 'Unsupported identifier type "{0}"'.format(identifier_type)) # 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([san]), 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([san ]), 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, identifier_type=identifier_type, identifier=identifier, 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 _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True, external_account_binding=None): ''' Registers a new ACME account. Returns a pair ``(created, data)``. Here, ``created`` is ``True`` if the account was created and ``False`` if it already existed (e.g. it was not newly created), or does not exist. In case the account was created or exists, ``data`` contains the account data; otherwise, it is ``None``. 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 ''' contact = contact or [] if self.client.version == 1: new_reg = {'resource': 'new-reg', 'contact': contact} if agreement: new_reg['agreement'] = agreement else: new_reg['agreement'] = self.client.directory['meta'][ 'terms-of-service'] if external_account_binding is not None: raise ModuleFailException( 'External account binding is not supported for ACME v1') url = self.client.directory['new-reg'] else: if (external_account_binding is not None or self.client.directory['meta'].get( 'externalAccountRequired')) and allow_creation: # Some ACME servers such as ZeroSSL do not like it when you try to register an existing account # and provide external_account_binding credentials. Thus we first send a request with allow_creation=False # to see whether the account already exists. # Note that we pass contact here: ZeroSSL does not accept regisration calls without contacts, even # if onlyReturnExisting is set to true. created, data = self._new_reg(contact=contact, allow_creation=False) if data: # An account already exists! Return data return created, data # An account does not yet exist. Try to create one next. new_reg = {'contact': contact} if not allow_creation: # https://tools.ietf.org/html/rfc8555#section-7.3.1 new_reg['onlyReturnExisting'] = True if terms_agreed: new_reg['termsOfServiceAgreed'] = True url = self.client.directory['newAccount'] if external_account_binding is not None: new_reg['externalAccountBinding'] = self.client.sign_request( { 'alg': external_account_binding['alg'], 'kid': external_account_binding['kid'], 'url': url, }, self.client.account_jwk, self.client.backend.create_mac_key( external_account_binding['alg'], external_account_binding['key'])) elif self.client.directory['meta'].get( 'externalAccountRequired') and allow_creation: raise ModuleFailException( 'To create an account, an external account binding must be specified. ' 'Use the acme_account module with the external_account_binding option.' ) result, info = self.client.send_signed_request(url, new_reg, fail_on_error=False) if info['status'] in ([200, 201] if self.client.version == 1 else [201]): # Account did not exist if 'location' in info: self.client.set_account_uri(info['location']) return True, result elif info['status'] == (409 if self.client.version == 1 else 200): # Account did exist if result.get('status') == 'deactivated': # A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and # Boulder (https://github.com/letsencrypt/boulder/issues/3971): this should # not return a valid account object according to # https://tools.ietf.org/html/rfc8555#section-7.3.6: # "Once an account is deactivated, the server MUST NOT accept further # requests authorized by that account's key." if not allow_creation: return False, None else: raise ModuleFailException("Account is deactivated") if 'location' in info: self.client.set_account_uri(info['location']) return False, result elif info['status'] == 400 and result[ 'type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation: # Account does not exist (and we didn't try to create it) return False, None elif info['status'] == 403 and result[ 'type'] == 'urn:ietf:params:acme:error:unauthorized' and 'deactivated' in ( result.get('detail') or ''): # Account has been deactivated; currently works for Pebble; hasn't been # implemented for Boulder (https://github.com/letsencrypt/boulder/issues/3971), # might need adjustment in error detection. if not allow_creation: return False, None else: raise ModuleFailException("Account is deactivated") else: raise ACMEProtocolException(self.client.module, msg='Registering ACME account failed', info=info, content_json=result)
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)