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 _get_challenge_data(self, auth, domain): ''' Returns a dict with the data for all proposed (and supported) challenges of the given authorization. ''' data = {} # no need to choose a specific challenge here as this module # is not responsible for fulfilling the challenges. Calculate # and return the required information for each challenge. for challenge in auth['challenges']: type = challenge['type'] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) if type == 'http-01': # https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-8.3 resource = '.well-known/acme-challenge/' + token data[type] = {'resource': resource, 'resource_value': keyauthorization} elif type == 'dns-01': # https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-8.4 resource = '_acme-challenge' value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest()) record = (resource + domain[1:]) if domain.startswith('*.') else (resource + '.' + domain) data[type] = {'resource': resource, 'resource_value': value, 'record': record} elif type == 'tls-alpn-01': # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3 resource = domain value = base64.b64encode(hashlib.sha256(to_bytes(keyauthorization)).digest()) data[type] = {'resource': resource, 'resource_value': value} else: continue return data
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 _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 _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 _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 _get_challenge_data(self, auth, identifier_type, identifier): ''' Returns a dict with the data for all proposed (and supported) challenges of the given authorization. ''' data = {} # no need to choose a specific challenge here as this module # is not responsible for fulfilling the challenges. Calculate # and return the required information for each challenge. for challenge in auth['challenges']: challenge_type = challenge['type'] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) if challenge_type == 'http-01': # https://tools.ietf.org/html/rfc8555#section-8.3 resource = '.well-known/acme-challenge/' + token data[challenge_type] = { 'resource': resource, 'resource_value': keyauthorization } elif challenge_type == 'dns-01': if identifier_type != 'dns': continue # https://tools.ietf.org/html/rfc8555#section-8.4 resource = '_acme-challenge' value = nopad_b64( hashlib.sha256(to_bytes(keyauthorization)).digest()) record = (resource + identifier[1:]) if identifier.startswith('*.') else ( resource + '.' + identifier) data[challenge_type] = { 'resource': resource, 'resource_value': value, 'record': record } elif challenge_type == 'tls-alpn-01': # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3 if identifier_type == 'ip': # IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596) resource = compat_ipaddress.ip_address( identifier).reverse_pointer if not resource.endswith('.'): resource += '.' else: resource = identifier value = base64.b64encode( hashlib.sha256(to_bytes(keyauthorization)).digest()) data[challenge_type] = { 'resource': resource, 'resource_original': identifier_type + ':' + identifier, 'resource_value': value } else: continue return data
def _get_challenge_data(self, auth, identifier_type, identifier): ''' Returns a dict with the data for all proposed (and supported) challenges of the given authorization. ''' data = {} # no need to choose a specific challenge here as this module # is not responsible for fulfilling the challenges. Calculate # and return the required information for each challenge. for challenge in auth['challenges']: challenge_type = challenge['type'] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) if challenge_type == 'http-01': # https://tools.ietf.org/html/rfc8555#section-8.3 resource = '.well-known/acme-challenge/' + token data[challenge_type] = {'resource': resource, 'resource_value': keyauthorization} elif challenge_type == 'dns-01': if identifier_type != 'dns': continue # https://tools.ietf.org/html/rfc8555#section-8.4 resource = '_acme-challenge' value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest()) record = (resource + identifier[1:]) if identifier.startswith('*.') else (resource + '.' + identifier) data[challenge_type] = {'resource': resource, 'resource_value': value, 'record': record} elif challenge_type == 'tls-alpn-01': # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3 if identifier_type == 'ip': if ':' in identifier: # IPv6 address: use reverse IP6.ARPA mapping (RFC3596) i = identifier.find('::') if i >= 0: nibbles = [nibble for nibble in identifier[:i].split(':') if nibble] suffix = [nibble for nibble in identifier[i + 1:].split(':') if nibble] if len(nibbles) + len(suffix) < 8: nibbles.extend(['0'] * (8 - len(nibbles) - len(suffix))) nibbles.extend(suffix) else: nibbles = identifier.split(':') resource = [] for nibble in reversed(nibbles): nibble = '0' * (4 - len(nibble)) + nibble.lower() for octet in reversed(nibble): resource.append(octet) resource = '.'.join(resource) + '.ip6.arpa.' else: # IPv4 address: use reverse IN-ADDR.ARPA mapping (RFC1034) resource = '.'.join(reversed(identifier.split('.'))) + '.in-addr.arpa.' else: resource = identifier value = base64.b64encode(hashlib.sha256(to_bytes(keyauthorization)).digest()) data[challenge_type] = {'resource': resource, 'resource_original': identifier_type + ':' + identifier, 'resource_value': value} else: continue return data
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 test_nopad_b64(value, result): assert nopad_b64(value.encode('utf-8')) == 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), 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(): 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)