ACME modules refactor (#187)

* Move acme.py to acme/__init__.py to prepare splitup.

* Began moving generic code out.

* Creating backends.

* Update unit tests.

* Move remaining new code out.

* Use new interface.

* Rewrite module init code.

* Add changelog.

* Add BackendException for crypto backend errors.

* Improve / uniformize ACME error reporting.

* Create ACMELegacyAccount for backwards compatibility.

* Split up ACMEAccount into ACMEClient and ACMEAccount.

* Move get_keyauthorization into module_utils.acme.challenges.

* Improve error handling.

* Move challenge and authorization handling code into module_utils.

* Add split_identifier helper.

* Move order code into module_utils.

* Move ACME v2 certificate handling code to module_utils.

* Fix/move ACME v1 certificate retrieval to module_utils as well.

* Refactor alternate chain handling code by splitting it up into simpler functions.

* Make chain matcher creation part of backend.
This commit is contained in:
Felix Fontein
2021-03-21 09:40:25 +01:00
committed by GitHub
parent 8de9376a10
commit 5d32937321
30 changed files with 3029 additions and 1977 deletions

View File

@@ -158,11 +158,19 @@ import base64
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
ACMEAccount,
handle_standard_module_arguments,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
KeyParsingError,
)
@@ -197,7 +205,7 @@ def main():
),
supports_check_mode=True,
)
handle_standard_module_arguments(module, needs_acme_v2=True)
backend = create_backend(module, True)
if module.params['external_account_binding']:
# Make sure padding is there
@@ -212,7 +220,8 @@ def main():
module.params['external_account_binding']['key'] = key
try:
account = ACMEAccount(module)
client = ACMEClient(module, backend)
account = ACMEAccount(client)
changed = False
state = module.params.get('state')
diff_before = {}
@@ -221,7 +230,7 @@ def main():
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']
diff_before['public_account_key'] = client.account_key_data['jwk']
if created:
raise AssertionError('Unwanted account creation')
if account_data is not None:
@@ -231,9 +240,8 @@ def main():
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))
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')
@@ -252,21 +260,22 @@ def main():
diff_before = {}
else:
diff_before = dict(account_data)
diff_before['public_account_key'] = account.key_data['jwk']
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'] = account.key_data['jwk']
diff_after['public_account_key'] = client.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)
try:
new_key_data = client.parse_key(
module.params.get('new_account_key_src'),
module.params.get('new_account_key_content')
)
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:
@@ -274,30 +283,29 @@ def main():
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']
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 = account.directory['keyChange']
url = client.directory['keyChange']
protected = {
"alg": new_key_data['alg'],
"jwk": new_key_data['jwk'],
"url": url,
}
payload = {
"account": account.uri,
"account": client.account_uri,
"newKey": new_key_data['jwk'], # specified in draft 12 and older
"oldKey": account.jwk, # specified in draft 13 and newer
"oldKey": client.account_jwk, # specified in draft 13 and newer
}
data = account.sign_request(protected, payload, new_key_data)
data = client.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))
result, info = client.send_signed_request(
url, data, error_msg='Failed to rollover account key', expected_status_codes=[200])
if module._diff:
account.key_data = new_key_data
account.jws_header['alg'] = new_key_data['alg']
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
@@ -306,7 +314,7 @@ def main():
changed = True
result = {
'changed': changed,
'account_uri': account.uri,
'account_uri': client.account_uri,
}
if module._diff:
result['diff'] = {

View File

@@ -213,23 +213,31 @@ order_uris:
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
ACMEAccount,
handle_standard_module_arguments,
process_links,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
process_links,
)
def get_orders_list(module, account, orders_url):
def get_orders_list(module, client, orders_url):
'''
Retrieves orders list (handles pagination).
'''
orders = []
while orders_url:
# Get part of orders list
res, info = account.get_request(orders_url, parse_json_result=True, fail_on_error=True)
res, info = client.get_request(orders_url, parse_json_result=True, fail_on_error=True)
if not res.get('orders'):
if orders:
module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url))
@@ -252,11 +260,11 @@ def get_orders_list(module, account, orders_url):
return orders
def get_order(account, order_url):
def get_order(client, order_url):
'''
Retrieve order data.
'''
return account.get_request(order_url, parse_json_result=True, fail_on_error=True)[0]
return client.get_request(order_url, parse_json_result=True, fail_on_error=True)[0]
def main():
@@ -277,10 +285,11 @@ def main():
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')
handle_standard_module_arguments(module, needs_acme_v2=True)
backend = create_backend(module, True)
try:
account = ACMEAccount(module)
client = ACMEClient(module, backend)
account = ACMEAccount(client)
# Check whether account exists
created, account_data = account.setup_account(
[],
@@ -291,18 +300,18 @@ def main():
raise AssertionError('Unwanted account creation')
result = {
'changed': False,
'exists': account.uri is not None,
'account_uri': account.uri,
'exists': client.account_uri is not None,
'account_uri': client.account_uri,
}
if account.uri is not None:
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'] = account.key_data['jwk']
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, account, account_data['orders'])
orders = get_orders_list(module, client, account_data['orders'])
result['order_uris'] = orders
if module.params['retrieve_orders'] == 'url_list':
module.deprecate(
@@ -312,7 +321,7 @@ def main():
version='2.0.0', collection_name='community.crypto')
result['orders'] = orders
if module.params['retrieve_orders'] == 'object_list':
result['orders'] = [get_order(account, order) for order in orders]
result['orders'] = [get_order(client, order) for order in orders]
module.exit_json(**result)
except ModuleFailException as e:
e.do_fail(module)

View File

@@ -508,92 +508,57 @@ all_chains:
returned: always
'''
import base64
import binascii
import hashlib
import os
import re
import textwrap
import time
import traceback
from datetime import datetime
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_bytes, to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
parse_name_field,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_name_to_oid,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
split_pem_list,
)
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
write_file,
nopad_b64,
pem_to_der,
ACMEAccount,
HAS_CURRENT_CRYPTOGRAPHY,
cryptography_get_csr_identifiers,
openssl_get_csr_identifiers,
cryptography_get_cert_days,
handle_standard_module_arguments,
process_links,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
try:
import cryptography
import cryptography.hazmat.backends
import cryptography.x509
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
combine_identifier,
split_identifier,
Authorization,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
retrieve_acme_v1_certificate,
CertificateChain,
Criterium,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
write_file,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.orders import (
Order,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
pem_to_der,
)
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
class ACMEClient(object):
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):
def __init__(self, module, backend):
self.module = module
self.version = module.params['acme_version']
self.challenge = module.params['challenge']
@@ -602,13 +567,22 @@ class ACMEClient(object):
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.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.finalize_uri = 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']
@@ -639,286 +613,8 @@ class ACMEClient(object):
if self.csr is not None and 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 _get_csr_identifiers(self):
'''
Parse the CSR and return the list of requested identifiers
'''
if HAS_CURRENT_CRYPTOGRAPHY:
return cryptography_get_csr_identifiers(self.module, self.csr, self.csr_content)
else:
return openssl_get_csr_identifiers(self._openssl_bin, self.module, self.csr, self.csr_content)
def _add_or_update_auth(self, identifier_type, identifier, auth):
'''
Add or update the given authorization in the global authorizations list.
Return True if the auth was updated/added and False if no change was
necessary.
'''
if self.authorizations.get(identifier_type + ':' + identifier) == auth:
return False
self.authorizations[identifier_type + ':' + identifier] = auth
return True
def _new_authz_v1(self, identifier_type, identifier):
'''
Create a new authorization for the given identifier.
Return the authorization object of the new authorization
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
'''
new_authz = {
"resource": "new-authz",
"identifier": {"type": identifier_type, "value": identifier},
}
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 _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://www.rfc-editor.org/rfc/rfc8737.html#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 _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 _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.
'''
found_challenge = False
for challenge in auth['challenges']:
if self.challenge != challenge['type']:
continue
uri = challenge['uri'] if self.version == 1 else challenge['url']
found_challenge = True
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))
if not found_challenge:
raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}:{2}!".format(
self.challenge, identifier_type, identifier))
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 _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, self.csr_content)
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))
status = result['status']
while status not in ['valid', 'invalid']:
time.sleep(2)
result, dummy = self.account.get_request(self.order_uri)
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 _der_to_pem(self, der_cert):
'''
Convert the DER format certificate in der_cert to a PEM format
certificate and return it.
'''
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
"\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64)))
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
certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True)
if certs:
cert = certs[0]
chain = certs[1:]
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:
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 _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, self.csr_content)
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]:
del chain[:]
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 _new_order_v2(self):
'''
Start a new certificate order (ACME v2 protocol).
https://tools.ietf.org/html/rfc8555#section-7.4
'''
identifiers = []
for identifier_type, identifier in self.identifiers:
identifiers.append({
'type': identifier_type,
'value': identifier,
})
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
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.order_uri = info['location']
self.finalize_uri = result['finalize']
self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
def is_first_step(self):
'''
@@ -946,10 +642,13 @@ class ACMEClient(object):
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)
authz = Authorization.create(self.client, identifier_type, identifier)
self.authorizations[authz.combined_identifier] = authz
else:
self._new_order_v2()
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):
@@ -959,15 +658,14 @@ class ACMEClient(object):
'''
# Get general challenge data
data = {}
for type_identifier, auth in self.authorizations.items():
identifier_type, identifier = type_identifier.split(':', 1)
auth = self.authorizations[type_identifier]
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 auth['status'] == 'valid':
if authz.status == 'valid':
continue
# We drop the type from the key to preserve backwards compatibility
data[identifier] = self._get_challenge_data(auth, identifier_type, identifier)
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))
@@ -994,91 +692,40 @@ class ACMEClient(object):
# 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)
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.
result, info = self.account.get_request(self.order_uri)
self.order = Order.from_url(self.client, self.order_uri)
self.order.load_authorizations(self.client)
self.authorizations.update(self.order.authorizations)
if not result:
raise ModuleFailException("Cannot download order from {0}: {1} (headers: {2})".format(self.order_uri, result, info))
# 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
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 _chain_matches(self, chain, criterium):
'''
Check whether an alternate chain matches the specified criterium.
'''
if criterium['test_certificates'] == 'last':
chain = chain[-1:]
elif criterium['test_certificates'] == 'first':
chain = chain[:1]
for cert in chain:
def download_alternate_chains(self, cert):
alternate_chains = []
for alternate in cert.alternates:
try:
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend())
matches = True
if criterium['subject']:
for k, v in parse_name_field(criterium['subject']):
oid = cryptography_name_to_oid(k)
value = to_native(v)
found = False
for attribute in x509.subject:
if attribute.oid == oid and value == to_native(attribute.value):
found = True
break
if not found:
matches = False
break
if criterium['issuer']:
for k, v in parse_name_field(criterium['issuer']):
oid = cryptography_name_to_oid(k)
value = to_native(v)
found = False
for attribute in x509.issuer:
if attribute.oid == oid and value == to_native(attribute.value):
found = True
break
if not found:
matches = False
break
if criterium['subject_key_identifier']:
try:
ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier)
if criterium['subject_key_identifier'] != ext.value.digest:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if criterium['authority_key_identifier']:
try:
ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier)
if criterium['authority_key_identifier'] != ext.value.key_identifier:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if matches:
return True
except Exception as e:
self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e))
return False
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):
'''
@@ -1087,80 +734,46 @@ class ACMEClient(object):
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']))
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 = self._new_cert_v1()
cert = retrieve_acme_v1_certificate(self.client, pem_to_der(self.csr, self.csr_content))
else:
cert_uri = self._finalize_cert()
cert = self._download_cert(cert_uri)
if self.module.params['retrieve_all_alternates'] or self.module.params['select_chain']:
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 = []
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)
alternate_chains = self.download_alternate_chains(cert)
# Prepare return value for all alternate chains
if self.module.params['retrieve_all_alternates']:
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)
self.all_chains = [cert.to_json()]
for alt_chain in alternate_chains:
_append_all_chains(alt_chain)
self.all_chains.append(alt_chain.to_json())
# Try to select alternate chain depending on criteria
if self.module.params['select_chain']:
matching_chain = None
all_chains = [cert] + alternate_chains
for criterium_idx, criterium in enumerate(self.module.params['select_chain']):
for v in ('subject_key_identifier', 'authority_key_identifier'):
if criterium[v]:
try:
criterium[v] = binascii.unhexlify(criterium[v].replace(':', ''))
except Exception:
self.module.warn('Criterium {0} in select_chain has invalid {1} value. '
'Ignoring criterium.'.format(criterium_idx, v))
continue
for alt_chain in all_chains:
if self._chain_matches(alt_chain.get('chain', []), criterium):
self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
matching_chain = alt_chain
break
if matching_chain:
break
if self.select_chain_matcher:
matching_chain = self.find_matching_chain([cert] + alternate_chains)
if matching_chain:
cert.update(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 = list(cert.get('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 = get_cert_days(self.module, self.dest)
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 = get_cert_days(self.module, self.fullchain_dest)
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')):
@@ -1172,25 +785,14 @@ class ACMEClient(object):
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
'''
authz_deactivate = {
'status': 'deactivated'
}
if self.version == 1:
authz_deactivate['resource'] = 'authz'
if self.authorizations:
for identifier_type, identifier in self.identifiers:
auth = self.authorizations.get(identifier_type + ':' + identifier)
if auth is None or auth.get('status') != 'valid':
continue
try:
result, info = self.account.send_signed_request(auth['uri'], authz_deactivate)
if 200 <= info['status'] < 300 and result.get('status') == 'deactivated':
auth['status'] = 'deactivated'
except Exception as dummy:
# Ignore errors on deactivating authzs
pass
if auth.get('status') != 'deactivated':
self.module.warn(warning='Could not deactivate authz object {0}.'.format(auth['uri']))
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():
@@ -1232,18 +834,13 @@ def main():
),
supports_check_mode=True,
)
backend = handle_standard_module_arguments(module)
if module.params['select_chain']:
if backend != 'cryptography':
module.fail_json(msg="The 'select_chain' can only be used with the 'cryptography' backend.")
elif not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography'))
backend = create_backend(module, False)
try:
if module.params.get('dest'):
cert_days = get_cert_days(module, module.params['dest'])
cert_days = backend.get_cert_days(module.params['dest'])
else:
cert_days = get_cert_days(module, module.params['fullchain_dest'])
cert_days = backend.get_cert_days(module.params['fullchain_dest'])
if module.params['force'] or cert_days < module.params['remaining_days']:
# If checkmode is active, base the changed state solely on the status
@@ -1253,7 +850,7 @@ def main():
if module.check_mode:
module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
else:
client = ACMEClient(module)
client = ACMECertificateClient(module, backend)
client.cert_days = cert_days
other = dict()
is_first_step = client.is_first_step()
@@ -1265,7 +862,7 @@ def main():
try:
client.finish_challenges()
client.get_certificate()
if module.params['retrieve_all_alternates']:
if client.all_chains is not None:
other['all_chains'] = client.all_chains
finally:
if module.params['deactivate_authzs']:
@@ -1274,13 +871,13 @@ def main():
auths = dict()
for k, v in client.authorizations.items():
# Remove "type:" from key
auths[k.split(':', 1)[1]] = v
auths[split_identifier(k)[1]] = v.to_json()
module.exit_json(
changed=client.changed,
authorizations=auths,
finalize_uri=client.finalize_uri,
finalize_uri=client.order.finalize_uri if client.order else None,
order_uri=client.order_uri,
account_uri=client.account.uri,
account_uri=client.client.account_uri,
challenge_data=data,
challenge_data_dns=data_dns,
cert_days=client.cert_days,

View File

@@ -119,13 +119,25 @@ RETURN = '''#'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
ModuleFailException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
pem_to_der,
handle_standard_module_arguments,
get_default_argspec,
)
@@ -147,10 +159,11 @@ def main():
),
supports_check_mode=False,
)
handle_standard_module_arguments(module)
backend = create_backend(module, False)
try:
account = ACMEAccount(module)
client = ACMEClient(module, backend)
account = ACMEAccount(client)
# Load certificate
certificate = pem_to_der(module.params.get('certificate'))
certificate = nopad_b64(certificate)
@@ -162,25 +175,27 @@ def main():
payload['reason'] = module.params.get('revoke_reason')
# Determine endpoint
if module.params.get('acme_version') == 1:
endpoint = account.directory['revoke-cert']
endpoint = client.directory['revoke-cert']
payload['resource'] = 'revoke-cert'
else:
endpoint = account.directory['revokeCert']
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:
# 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)
try:
private_key_data = client.parse_key(private_key, private_key_content)
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 = account.send_signed_request(endpoint, payload, key_data=private_key_data, jws_header=jws_header)
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)
@@ -189,7 +204,7 @@ def main():
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)
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)
@@ -208,7 +223,7 @@ def main():
# 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))
raise ACMEProtocolException('Failed to revoke certificate', info=info, content_json=result)
module.exit_json(changed=True)
except ModuleFailException as e:
e.do_fail(module)

View File

@@ -140,8 +140,9 @@ import traceback
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
read_file,
)

View File

@@ -240,16 +240,18 @@ output_json:
- ...
'''
import json
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native, to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
ACMEAccount,
handle_standard_module_arguments,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
ModuleFailException,
)
@@ -273,25 +275,26 @@ def main():
['method', 'post', ['account_key_src', 'account_key_content'], True],
),
)
handle_standard_module_arguments(module)
backend = create_backend(module, False)
result = dict()
changed = False
try:
# Get hold of ACMEAccount object (includes directory)
account = ACMEAccount(module)
# Get hold of ACMEClient and ACMEAccount objects (includes directory)
client = ACMEClient(module, backend)
method = module.params['method']
result['directory'] = account.directory.directory
result['directory'] = client.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)
data, info = client.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)
data, info = client.send_signed_request(
url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False, fail_on_error=False)
# Update results
result.update(dict(
headers=info,
@@ -299,13 +302,12 @@ def main():
))
# See if we can parse the result as JSON
try:
# to_text() is needed only for Python 3.5 (and potentially 3.0 to 3.4 as well)
result['output_json'] = json.loads(to_text(data))
result['output_json'] = module.from_json(to_text(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))
raise ACMEProtocolException(info=info, content_json=result)
# Done!
module.exit_json(changed=changed, **result)
except ModuleFailException as e: