From 5d329373216af3d7a427cc5ccbbb4ccf2c72b018 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 21 Mar 2021 09:40:25 +0100 Subject: [PATCH] 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. --- changelogs/fragments/184-acme-refactor.yml | 5 + plugins/module_utils/acme.py | 1156 ----------------- plugins/module_utils/acme/__init__.py | 90 ++ plugins/module_utils/acme/_compatibility.py | 267 ++++ plugins/module_utils/acme/account.py | 251 ++++ plugins/module_utils/acme/acme.py | 366 ++++++ .../module_utils/acme/backend_cryptography.py | 369 ++++++ .../module_utils/acme/backend_openssl_cli.py | 295 +++++ plugins/module_utils/acme/backends.py | 58 + plugins/module_utils/acme/certificates.py | 128 ++ plugins/module_utils/acme/challenges.py | 296 +++++ plugins/module_utils/acme/errors.py | 117 ++ plugins/module_utils/acme/io.py | 86 ++ plugins/module_utils/acme/orders.py | 125 ++ plugins/module_utils/acme/utils.py | 71 + plugins/modules/acme_account.py | 66 +- plugins/modules/acme_account_info.py | 43 +- plugins/modules/acme_certificate.py | 653 ++-------- plugins/modules/acme_certificate_revoke.py | 43 +- plugins/modules/acme_challenge_cert_helper.py | 5 +- plugins/modules/acme_inspect.py | 32 +- tests/sanity/ignore-2.10.txt | 1 + tests/sanity/ignore-2.11.txt | 1 + tests/sanity/ignore-2.9.txt | 1 + .../plugins/module_utils/acme/backend_data.py | 76 ++ .../plugins/module_utils/acme/test_acme.py | 216 --- .../acme/test_backend_cryptography.py | 64 + .../acme/test_backend_openssl_cli.py | 61 + .../unit/plugins/module_utils/acme/test_io.py | 29 + .../plugins/module_utils/acme/test_utils.py | 35 + 30 files changed, 3029 insertions(+), 1977 deletions(-) create mode 100644 changelogs/fragments/184-acme-refactor.yml delete mode 100644 plugins/module_utils/acme.py create mode 100644 plugins/module_utils/acme/__init__.py create mode 100644 plugins/module_utils/acme/_compatibility.py create mode 100644 plugins/module_utils/acme/account.py create mode 100644 plugins/module_utils/acme/acme.py create mode 100644 plugins/module_utils/acme/backend_cryptography.py create mode 100644 plugins/module_utils/acme/backend_openssl_cli.py create mode 100644 plugins/module_utils/acme/backends.py create mode 100644 plugins/module_utils/acme/certificates.py create mode 100644 plugins/module_utils/acme/challenges.py create mode 100644 plugins/module_utils/acme/errors.py create mode 100644 plugins/module_utils/acme/io.py create mode 100644 plugins/module_utils/acme/orders.py create mode 100644 plugins/module_utils/acme/utils.py create mode 100644 tests/unit/plugins/module_utils/acme/backend_data.py delete mode 100644 tests/unit/plugins/module_utils/acme/test_acme.py create mode 100644 tests/unit/plugins/module_utils/acme/test_backend_cryptography.py create mode 100644 tests/unit/plugins/module_utils/acme/test_backend_openssl_cli.py create mode 100644 tests/unit/plugins/module_utils/acme/test_io.py create mode 100644 tests/unit/plugins/module_utils/acme/test_utils.py diff --git a/changelogs/fragments/184-acme-refactor.yml b/changelogs/fragments/184-acme-refactor.yml new file mode 100644 index 00000000..062ea0fc --- /dev/null +++ b/changelogs/fragments/184-acme-refactor.yml @@ -0,0 +1,5 @@ +minor_changes: +- "acme module_utils - the ``acme`` module_utils has been split up into several Python modules (https://github.com/ansible-collections/community.crypto/pull/184)." +- "acme_* modules - codebase refactor which should not be visible to end-users (https://github.com/ansible-collections/community.crypto/pull/184)." +deprecated_features: +- "acme module_utils - the ``acme`` module_utils (``ansible_collections.community.crypto.plugins.module_utils.acme``) is deprecated and will be removed in community.crypto 2.0.0. Use the new Python modules in the ``acme`` package instead (``ansible_collections.community.crypto.plugins.module_utils.acme.xxx``) (https://github.com/ansible-collections/community.crypto/pull/184)." diff --git a/plugins/module_utils/acme.py b/plugins/module_utils/acme.py deleted file mode 100644 index 1026caa3..00000000 --- a/plugins/module_utils/acme.py +++ /dev/null @@ -1,1156 +0,0 @@ -# -*- coding: utf-8 -*- - -# (c) 2016 Michael Gruener -# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -import base64 -import binascii -import copy -import datetime -import hashlib -import json -import locale -import os -import re -import shutil -import sys -import tempfile -import traceback - -from ansible.module_utils.basic import missing_required_lib -from ansible.module_utils.urls import fetch_url -from ansible.module_utils.six.moves.urllib.parse import unquote -from ansible.module_utils._text import to_native, to_text, to_bytes - -from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress - -try: - import cryptography - import cryptography.hazmat.backends - import cryptography.hazmat.primitives.hashes - import cryptography.hazmat.primitives.hmac - import cryptography.hazmat.primitives.asymmetric.ec - import cryptography.hazmat.primitives.asymmetric.padding - import cryptography.hazmat.primitives.asymmetric.rsa - import cryptography.hazmat.primitives.asymmetric.utils - import cryptography.hazmat.primitives.serialization - import cryptography.x509 - import cryptography.x509.oid - from distutils.version import LooseVersion - CRYPTOGRAPHY_VERSION = cryptography.__version__ - HAS_CURRENT_CRYPTOGRAPHY = (LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion('1.5')) - if HAS_CURRENT_CRYPTOGRAPHY: - _cryptography_backend = cryptography.hazmat.backends.default_backend() -except Exception as dummy: - HAS_CURRENT_CRYPTOGRAPHY = False - - -class ModuleFailException(Exception): - ''' - If raised, module.fail_json() will be called with the given parameters after cleanup. - ''' - def __init__(self, msg, **args): - super(ModuleFailException, self).__init__(self, msg) - self.msg = msg - self.module_fail_args = args - - def do_fail(self, module, **arguments): - module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments) - - -def nopad_b64(data): - return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "") - - -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)) - - -# function source: network/basics/uri.py -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 pem_to_der(pem_filename, 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() - else: - 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()) - 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 _parse_key_openssl(openssl_binary, module, key_file=None, key_content=None): - ''' - Parses an RSA or Elliptic Curve key file in PEM format and returns a pair - (error, key_data). - ''' - # If key_file isn't given, but key_content, write that to a temporary file - if key_file is 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(key_content.encode('utf-8')) - key_file = tmpsrc - except Exception as err: - try: - f.close() - except Exception as dummy: - pass - raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) - f.close() - # Parse key - account_key_type = None - with open(key_file, "rt") as f: - for line in f: - m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line) - if m is not None: - account_key_type = m.group(1).lower() - break - if account_key_type is None: - # This happens for example if openssl_privatekey created this key - # (as opposed to the OpenSSL binary). For now, we assume this is - # an RSA key. - # FIXME: add some kind of auto-detection - account_key_type = "rsa" - if account_key_type not in ("rsa", "ec"): - return 'unknown key type "%s"' % account_key_type, {} - - openssl_keydump_cmd = [openssl_binary, account_key_type, "-in", key_file, "-noout", "-text"] - dummy, out, dummy = module.run_command(openssl_keydump_cmd, check_rc=True) - - if account_key_type == 'rsa': - pub_hex, pub_exp = re.search( - r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups() - pub_exp = "{0:x}".format(int(pub_exp)) - if len(pub_exp) % 2: - pub_exp = "0{0}".format(pub_exp) - - return None, { - 'key_file': key_file, - 'type': 'rsa', - 'alg': 'RS256', - 'jwk': { - "kty": "RSA", - "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), - "n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), - }, - 'hash': 'sha256', - } - elif account_key_type == 'ec': - pub_data = re.search( - r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) - if pub_data is None: - return 'cannot parse elliptic curve key', {} - pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8")) - asn1_oid_curve = pub_data.group(2).lower() - nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None - if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256': - bits = 256 - alg = 'ES256' - hashalg = 'sha256' - point_size = 32 - curve = 'P-256' - elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384': - bits = 384 - alg = 'ES384' - hashalg = 'sha384' - point_size = 48 - curve = 'P-384' - elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521': - # Not yet supported on Let's Encrypt side, see - # https://github.com/letsencrypt/boulder/issues/2217 - bits = 521 - alg = 'ES512' - hashalg = 'sha512' - point_size = 66 - curve = 'P-521' - else: - return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {} - num_bytes = (bits + 7) // 8 - if len(pub_hex) != 2 * num_bytes: - return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {} - return None, { - 'key_file': key_file, - 'type': 'ec', - 'alg': alg, - 'jwk': { - "kty": "EC", - "crv": curve, - "x": nopad_b64(pub_hex[:num_bytes]), - "y": nopad_b64(pub_hex[num_bytes:]), - }, - 'hash': hashalg, - 'point_size': point_size, - } - - -def _create_mac_key_openssl(openssl_bin, module, alg, key): - if alg == 'HS256': - hashalg = 'sha256' - hashbytes = 32 - elif alg == 'HS384': - hashalg = 'sha384' - hashbytes = 48 - elif alg == 'HS512': - hashalg = 'sha512' - hashbytes = 64 - else: - raise ModuleFailException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg)) - key_bytes = base64.urlsafe_b64decode(key) - if len(key_bytes) < hashbytes: - raise ModuleFailException( - '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) - return { - 'type': 'hmac', - 'alg': alg, - 'jwk': { - 'kty': 'oct', - 'k': key, - }, - 'hash': hashalg, - } - - -def _sign_request_openssl(openssl_binary, module, payload64, protected64, key_data): - sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') - if key_data['type'] == 'hmac': - hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k']))) - cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"] - else: - cmd_postfix = ["-sign", key_data['key_file']] - openssl_sign_cmd = [openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix - - dummy, out, dummy = module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True) - - if key_data['type'] == 'ec': - dummy, der_out, dummy = module.run_command( - [openssl_binary, "asn1parse", "-inform", "DER"], - data=out, binary_data=True) - expected_len = 2 * key_data['point_size'] - sig = re.findall( - r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, - to_text(der_out, errors='surrogate_or_strict')) - if len(sig) != 2: - raise ModuleFailException( - "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( - to_text(der_out, errors='surrogate_or_strict'))) - sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] - sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] - out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) - - return { - "protected": protected64, - "payload": payload64, - "signature": nopad_b64(to_bytes(out)), - } - - -if sys.version_info[0] >= 3: - # Python 3 (and newer) - def _count_bytes(n): - return (n.bit_length() + 7) // 8 if n > 0 else 0 - - def _convert_int_to_bytes(count, no): - return no.to_bytes(count, byteorder='big') - - def _pad_hex(n, digits): - res = hex(n)[2:] - if len(res) < digits: - res = '0' * (digits - len(res)) + res - return res -else: - # Python 2 - def _count_bytes(n): - if n <= 0: - return 0 - h = '%x' % n - return (len(h) + 1) // 2 - - def _convert_int_to_bytes(count, n): - h = '%x' % n - if len(h) > 2 * count: - raise Exception('Number {1} needs more than {0} bytes!'.format(count, n)) - return ('0' * (2 * count - len(h)) + h).decode('hex') - - def _pad_hex(n, digits): - h = '%x' % n - if len(h) < digits: - h = '0' * (digits - len(h)) + h - return h - - -def _parse_key_cryptography(module, key_file=None, key_content=None): - ''' - Parses an RSA or Elliptic Curve key file in PEM format and returns a pair - (error, key_data). - ''' - # If key_content isn't given, read key_file - if key_content is None: - key_content = read_file(key_file) - else: - key_content = to_bytes(key_content) - # Parse key - try: - key = cryptography.hazmat.primitives.serialization.load_pem_private_key(key_content, password=None, backend=_cryptography_backend) - except Exception as e: - return 'error while loading key: {0}'.format(e), None - if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): - pk = key.public_key().public_numbers() - return None, { - 'key_obj': key, - 'type': 'rsa', - 'alg': 'RS256', - 'jwk': { - "kty": "RSA", - "e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)), - "n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)), - }, - 'hash': 'sha256', - } - elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): - pk = key.public_key().public_numbers() - if pk.curve.name == 'secp256r1': - bits = 256 - alg = 'ES256' - hashalg = 'sha256' - point_size = 32 - curve = 'P-256' - elif pk.curve.name == 'secp384r1': - bits = 384 - alg = 'ES384' - hashalg = 'sha384' - point_size = 48 - curve = 'P-384' - elif pk.curve.name == 'secp521r1': - # Not yet supported on Let's Encrypt side, see - # https://github.com/letsencrypt/boulder/issues/2217 - bits = 521 - alg = 'ES512' - hashalg = 'sha512' - point_size = 66 - curve = 'P-521' - else: - return 'unknown elliptic curve: {0}'.format(pk.curve.name), {} - num_bytes = (bits + 7) // 8 - return None, { - 'key_obj': key, - 'type': 'ec', - 'alg': alg, - 'jwk': { - "kty": "EC", - "crv": curve, - "x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)), - "y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)), - }, - 'hash': hashalg, - 'point_size': point_size, - } - else: - return 'unknown key type "{0}"'.format(type(key)), {} - - -def _create_mac_key_cryptography(module, alg, key): - if alg == 'HS256': - hashalg = cryptography.hazmat.primitives.hashes.SHA256 - hashbytes = 32 - elif alg == 'HS384': - hashalg = cryptography.hazmat.primitives.hashes.SHA384 - hashbytes = 48 - elif alg == 'HS512': - hashalg = cryptography.hazmat.primitives.hashes.SHA512 - hashbytes = 64 - else: - raise ModuleFailException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg)) - key_bytes = base64.urlsafe_b64decode(key) - if len(key_bytes) < hashbytes: - raise ModuleFailException( - '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) - return { - 'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC( - key_bytes, - hashalg(), - _cryptography_backend), - 'type': 'hmac', - 'alg': alg, - 'jwk': { - 'kty': 'oct', - 'k': key, - }, - } - - -def _sign_request_cryptography(module, payload64, protected64, key_data): - sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') - if 'mac_obj' in key_data: - mac = key_data['mac_obj']() - mac.update(sign_payload) - signature = mac.finalize() - elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): - padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15() - hashalg = cryptography.hazmat.primitives.hashes.SHA256 - signature = key_data['key_obj'].sign(sign_payload, padding, hashalg()) - elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): - if key_data['hash'] == 'sha256': - hashalg = cryptography.hazmat.primitives.hashes.SHA256 - elif key_data['hash'] == 'sha384': - hashalg = cryptography.hazmat.primitives.hashes.SHA384 - elif key_data['hash'] == 'sha512': - hashalg = cryptography.hazmat.primitives.hashes.SHA512 - ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg()) - r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(key_data['key_obj'].sign(sign_payload, ecdsa)) - rr = _pad_hex(r, 2 * key_data['point_size']) - ss = _pad_hex(s, 2 * key_data['point_size']) - signature = binascii.unhexlify(rr) + binascii.unhexlify(ss) - - return { - "protected": protected64, - "payload": payload64, - "signature": nopad_b64(signature), - } - - -def _assert_fetch_url_success(response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True): - if info['status'] < 0: - raise ModuleFailException(msg="Failure downloading %s, %s" % (info['url'], info['msg'])) - - if (300 <= info['status'] < 400 and not allow_redirect) or \ - (400 <= info['status'] < 500 and not allow_client_error) or \ - (info['status'] >= 500 and not allow_server_error): - raise ModuleFailException("ACME request failed: CODE: {0} MGS: {1} RESULT: {2}".format(info['status'], info['msg'], response)) - - -class ACMEDirectory(object): - ''' - The ACME server directory. Gives access to the available resources, - and allows to obtain a Replay-Nonce. The acme_directory URL - needs to support unauthenticated GET requests; ACME endpoints - requiring authentication are not supported. - https://tools.ietf.org/html/rfc8555#section-7.1.1 - ''' - - 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 __getitem__(self, key): - return self.directory[key] - - def get_nonce(self, resource=None): - url = self.directory_root if self.version == 1 else self.directory['newNonce'] - if resource is not None: - url = resource - dummy, info = fetch_url(self.module, url, method='HEAD') - if info['status'] not in (200, 204): - raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status'])) - return info['replay-nonce'] - - -class ACMEAccount(object): - ''' - ACME account object. Handles the authorized communication with the - ACME server. Provides access to account bound information like - the currently active authorizations and valid certificates - ''' - - def __init__(self, module): - # Set to true to enable logging of all signed requests - self._debug = False - - self.module = module - self.version = module.params['acme_version'] - # account_key path and content are mutually exclusive - self.key = module.params['account_key_src'] - self.key_content = module.params['account_key_content'] - - # Grab account URI from module parameters. - # Make sure empty string is treated as None. - self.uri = module.params.get('account_uri') or None - - self._openssl_bin = module.get_bin_path('openssl', True) - - if self.key is not None or self.key_content is not None: - error, self.key_data = self.parse_key(self.key, self.key_content) - if error: - raise ModuleFailException("error while parsing account key: %s" % error) - self.jwk = self.key_data['jwk'] - self.jws_header = { - "alg": self.key_data['alg'], - "jwk": self.jwk, - } - if self.uri: - # Make sure self.jws_header is updated - self.set_account_uri(self.uri) - - self.directory = ACMEDirectory(module, self) - - def get_keyauthorization(self, token): - ''' - Returns the key authorization for the given token - https://tools.ietf.org/html/rfc8555#section-8.1 - ''' - accountkey_json = json.dumps(self.jwk, sort_keys=True, separators=(',', ':')) - thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) - return "{0}.{1}".format(token, thumbprint) - - def parse_key(self, key_file=None, key_content=None): - ''' - Parses an RSA or Elliptic Curve key file in PEM format and returns a pair - (error, key_data). - ''' - if key_file is None and key_content is None: - raise AssertionError('One of key_file and key_content must be specified!') - if HAS_CURRENT_CRYPTOGRAPHY: - return _parse_key_cryptography(self.module, key_file, key_content) - else: - return _parse_key_openssl(self._openssl_bin, self.module, key_file, key_content) - - def sign_request(self, protected, payload, key_data, encode_payload=True): - 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)) - - if HAS_CURRENT_CRYPTOGRAPHY: - return _sign_request_cryptography(self.module, payload64, protected64, key_data) - else: - return _sign_request_openssl(self._openssl_bin, self.module, payload64, protected64, key_data) - - def _create_mac_key(self, alg, key): - '''Create a MAC key.''' - if HAS_CURRENT_CRYPTOGRAPHY: - return _create_mac_key_cryptography(self.module, alg, key) - else: - return _create_mac_key_openssl(self._openssl_bin, self.module, alg, key) - - def _log(self, msg, data=None): - ''' - Write arguments to acme.log when logging is enabled. - ''' - if self._debug: - with open('acme.log', 'ab') as f: - f.write('[{0}] {1}\n'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%s'), msg).encode('utf-8')) - if data is not None: - f.write('{0}\n\n'.format(json.dumps(data, indent=2, sort_keys=True)).encode('utf-8')) - - def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True): - ''' - Sends a JWS signed HTTP POST request to the ACME server and returns - the response as dictionary - https://tools.ietf.org/html/rfc8555#section-6.2 - - If payload is None, a POST-as-GET is performed. - (https://tools.ietf.org/html/rfc8555#section-6.3) - ''' - key_data = key_data or self.key_data - jws_header = jws_header or self.jws_header - failed_tries = 0 - while True: - protected = copy.deepcopy(jws_header) - protected["nonce"] = self.directory.get_nonce() - if self.version != 1: - protected["url"] = url - - self._log('URL', url) - self._log('protected', protected) - self._log('payload', payload) - data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload) - if self.version == 1: - data["header"] = jws_header.copy() - for k, v in protected.items(): - hv = data["header"].pop(k, None) - self._log('signed request', data) - data = self.module.jsonify(data) - - headers = { - 'Content-Type': 'application/jose+json', - } - resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST') - _assert_fetch_url_success(resp, info) - result = {} - try: - content = resp.read() - except AttributeError: - content = info.pop('body', None) - - if content or not parse_json_result: - if (parse_json_result and info['content-type'].startswith('application/json')) or 400 <= info['status'] < 600: - try: - decoded_result = self.module.from_json(content.decode('utf8')) - self._log('parsed result', decoded_result) - # In case of badNonce error, try again (up to 5 times) - # (https://tools.ietf.org/html/rfc8555#section-6.7) - if (400 <= info['status'] < 600 and - decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce' and - failed_tries <= 5): - failed_tries += 1 - continue - if parse_json_result: - result = decoded_result - else: - result = content - except ValueError: - raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content)) - else: - result = content - - return result, info - - def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True): - ''' - Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback - to GET if server replies with a status code of 405. - ''' - if not get_only and self.version != 1: - # Try POST-as-GET - content, info = self.send_signed_request(uri, None, parse_json_result=False) - if info['status'] == 405: - # Instead, do unauthenticated GET - get_only = True - else: - # Do unauthenticated GET - get_only = True - - if get_only: - # Perform unauthenticated GET - resp, info = fetch_url(self.module, uri, method='GET', headers=headers) - - _assert_fetch_url_success(resp, info) - - try: - content = resp.read() - except AttributeError: - content = info.pop('body', None) - - # Process result - if parse_json_result: - result = {} - if content: - if info['content-type'].startswith('application/json'): - try: - result = self.module.from_json(content.decode('utf8')) - except ValueError: - raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(uri, content)) - else: - result = content - else: - result = content - - if fail_on_error and (info['status'] < 200 or info['status'] >= 400): - raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result)) - return result, info - - def set_account_uri(self, uri): - ''' - Set account URI. For ACME v2, it needs to be used to sending signed - requests. - ''' - self.uri = uri - if self.version != 1: - self.jws_header.pop('jwk') - self.jws_header['kid'] = self.uri - - 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.version == 1: - new_reg = { - 'resource': 'new-reg', - 'contact': contact - } - if agreement: - new_reg['agreement'] = agreement - else: - new_reg['agreement'] = self.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.directory['new-reg'] - else: - if (external_account_binding is not None or self.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.directory['newAccount'] - if external_account_binding is not None: - new_reg['externalAccountBinding'] = self.sign_request( - { - 'alg': external_account_binding['alg'], - 'kid': external_account_binding['kid'], - 'url': url, - }, - self.jwk, - self._create_mac_key(external_account_binding['alg'], external_account_binding['key']) - ) - elif self.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.send_signed_request(url, new_reg) - - if info['status'] in ([200, 201] if self.version == 1 else [201]): - # Account did not exist - if 'location' in info: - self.set_account_uri(info['location']) - return True, result - elif info['status'] == (409 if self.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.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 ModuleFailException("Error registering: {0} {1}".format(info['status'], result)) - - 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.uri is None: - raise ModuleFailException("Account URI unknown") - if self.version == 1: - data = {} - data['resource'] = 'reg' - result, info = self.send_signed_request(self.uri, data) - else: - # try POST-as-GET first (draft-15 or newer) - data = None - result, info = self.send_signed_request(self.uri, data) - # 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.send_signed_request(self.uri, data) - 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 ModuleFailException("Error getting account data from {2}: {0} {1}".format(info['status'], result, self.uri)) - return result - - def setup_account(self, contact=None, agreement=None, terms_agreed=False, - allow_creation=True, remove_account_uri_if_not_exists=False, - external_account_binding=None): - ''' - Detect or create an account on the ACME server. For ACME v1, - as the only way (without knowing an account URI) to test if an - account exists is to try and create one with the provided account - key, this method will always result in an account being present - (except on error situations). For ACME v2, a new account will - only be created if ``allow_creation`` is set to True. - - For ACME v2, ``check_mode`` is fully respected. For ACME v1, the - account might be created if it does not yet exist. - - Return a pair ``(created, account_data)``. Here, ``created`` will - be ``True`` in case the account was created or would be created - (check mode). ``account_data`` will be the current account data, - or ``None`` if the account does not exist. - - The account URI will be stored in ``self.uri``; if it is ``None``, - the account does not exist. - - If specified, ``external_account_binding`` should be a dictionary - with keys ``kid``, ``alg`` and ``key`` - (https://tools.ietf.org/html/rfc8555#section-7.3.4). - - https://tools.ietf.org/html/rfc8555#section-7.3 - ''' - - if self.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.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.module.check_mode, - external_account_binding=external_account_binding, - ) - if self.module.check_mode and self.uri is None and allow_creation: - created = True - account_data = { - 'contact': contact or [] - } - return created, account_data - - def update_account(self, account_data, contact=None): - ''' - Update an account on the ACME server. Check mode is fully respected. - - The current account data must be provided as ``account_data``. - - Return a pair ``(updated, account_data)``, where ``updated`` is - ``True`` in case something changed (contact info updated) or - would be changed (check mode), and ``account_data`` the updated - account data. - - https://tools.ietf.org/html/rfc8555#section-7.3.2 - ''' - # Create request - update_request = {} - if contact is not None and account_data.get('contact', []) != contact: - update_request['contact'] = list(contact) - - # No change? - if not update_request: - return False, dict(account_data) - - # Apply change - if self.module.check_mode: - account_data = dict(account_data) - account_data.update(update_request) - else: - if self.version == 1: - update_request['resource'] = 'reg' - account_data, dummy = self.send_signed_request(self.uri, update_request) - return True, account_data - - -def _normalize_ip(ip): - try: - return to_native(compat_ipaddress.ip_address(to_text(ip)).compressed) - except ValueError: - # We don't want to error out on something IPAddress() can't parse - return ip - - -def openssl_get_csr_identifiers(openssl_binary, module, csr_filename, csr_content=None): - ''' - Return a set of requested identifiers (CN and SANs) for the CSR. - Each identifier is a pair (type, identifier), where type is either - 'dns' or 'ip'. - ''' - filename = csr_filename - data = None - if csr_content is not None: - filename = '-' - data = csr_content.encode('utf-8') - - openssl_csr_cmd = [openssl_binary, "req", "-in", filename, "-noout", "-text"] - dummy, out, dummy = module.run_command(openssl_csr_cmd, data=data, check_rc=True) - - identifiers = set([]) - common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict')) - if common_name is not None: - identifiers.add(('dns', common_name.group(1))) - subject_alt_names = re.search( - r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) - if subject_alt_names is not None: - for san in subject_alt_names.group(1).split(", "): - if san.lower().startswith("dns:"): - identifiers.add(('dns', san[4:])) - elif san.lower().startswith("ip:"): - identifiers.add(('ip', _normalize_ip(san[3:]))) - elif san.lower().startswith("ip address:"): - identifiers.add(('ip', _normalize_ip(san[11:]))) - else: - raise ModuleFailException('Found unsupported SAN identifier "{0}"'.format(san)) - return identifiers - - -def cryptography_get_csr_identifiers(module, csr_filename, csr_content=None): - ''' - Return a set of requested identifiers (CN and SANs) for the CSR. - Each identifier is a pair (type, identifier), where type is either - 'dns' or 'ip'. - ''' - identifiers = set([]) - if csr_content is None: - csr_content = read_file(csr_filename) - else: - csr_content = to_bytes(csr_content) - csr = cryptography.x509.load_pem_x509_csr(csr_content, _cryptography_backend) - for sub in csr.subject: - if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME: - identifiers.add(('dns', sub.value)) - for extension in csr.extensions: - if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: - for name in extension.value: - if isinstance(name, cryptography.x509.DNSName): - identifiers.add(('dns', name.value)) - elif isinstance(name, cryptography.x509.IPAddress): - identifiers.add(('ip', name.value.compressed)) - else: - raise ModuleFailException('Found unsupported SAN identifier {0}'.format(name)) - return identifiers - - -def cryptography_get_cert_days(module, cert_file, now=None): - ''' - 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 not os.path.exists(cert_file): - return -1 - - try: - cert = cryptography.x509.load_pem_x509_certificate(read_file(cert_file), _cryptography_backend) - except Exception as e: - raise ModuleFailException('Cannot parse certificate {0}: {1}'.format(cert_file, e)) - if now is None: - now = datetime.datetime.now() - return (cert.not_valid_after - now).days - - -def set_crypto_backend(module): - ''' - Sets which crypto backend to use (default: auto detection). - - Does not care whether a new enough cryptoraphy is available or not. Must - be called before any real stuff is done which might evaluate - ``HAS_CURRENT_CRYPTOGRAPHY``. - ''' - global HAS_CURRENT_CRYPTOGRAPHY - # Choose backend - backend = module.params['select_crypto_backend'] - if backend == 'auto': - pass - elif backend == 'openssl': - HAS_CURRENT_CRYPTOGRAPHY = False - elif backend == 'cryptography': - try: - cryptography.__version__ - except Exception as dummy: - module.fail_json(msg=missing_required_lib('cryptography')) - HAS_CURRENT_CRYPTOGRAPHY = True - else: - module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend)) - # Inform about choices - if HAS_CURRENT_CRYPTOGRAPHY: - module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION)) - return 'cryptography' - else: - module.debug('Using OpenSSL binary backend') - return 'openssl' - - -def process_links(info, callback): - ''' - Process link header, calls callback for every link header with the URL and relation as options. - ''' - if 'link' in info: - link = info['link'] - for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link): - callback(unquote(url), relation) - - -def get_default_argspec(): - ''' - Provides default argument spec for the options documented in the acme doc fragment. - ''' - return dict( - account_key_src=dict(type='path', aliases=['account_key']), - account_key_content=dict(type='str', no_log=True), - account_uri=dict(type='str'), - acme_directory=dict(type='str'), - acme_version=dict(type='int', choices=[1, 2]), - validate_certs=dict(type='bool', default=True), - select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']), - ) - - -def handle_standard_module_arguments(module, needs_acme_v2=False): - ''' - Do standard module setup, argument handling and warning emitting. - ''' - backend = set_crypto_backend(module) - - if not module.params['validate_certs']: - module.warn( - 'Disabling certificate validation for communications with ACME endpoint. ' - 'This should only be done for testing against a local ACME server for ' - 'development purposes, but *never* for production purposes.' - ) - - if module.params['acme_version'] is None: - module.params['acme_version'] = 1 - module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on", - version='2.0.0', collection_name='community.crypto') - - if module.params['acme_directory'] is None: - module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory' - module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on", - version='2.0.0', collection_name='community.crypto') - - if needs_acme_v2 and module.params['acme_version'] < 2: - module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name)) - - # AnsibleModule() changes the locale, so change it back to C because we rely on time.strptime() when parsing certificate dates. - module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') - locale.setlocale(locale.LC_ALL, 'C') - - return backend diff --git a/plugins/module_utils/acme/__init__.py b/plugins/module_utils/acme/__init__.py new file mode 100644 index 00000000..5c6c31c3 --- /dev/null +++ b/plugins/module_utils/acme/__init__.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import binascii +import copy +import datetime +import hashlib +import json +import locale +import os +import re +import shutil +import sys +import tempfile +import traceback + +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.urls import fetch_url +from ansible.module_utils.six.moves.urllib.parse import unquote +from ansible.module_utils._text import to_native, to_text, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + get_default_argspec, + ACMEDirectory, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import ( + CryptographyBackend, + CRYPTOGRAPHY_VERSION, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import ( + OpenSSLCLIBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import ( + handle_standard_module_arguments, + set_crypto_backend, + HAS_CURRENT_CRYPTOGRAPHY, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import ACMELegacyAccount as ACMEAccount + +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, + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, + pem_to_der, + process_links, +) + + +def openssl_get_csr_identifiers(openssl_binary, module, csr_filename, csr_content=None): + module.deprecate( + 'Please adjust your custom module/plugin to the ACME module_utils refactor ' + '(https://github.com/ansible-collections/community.crypto/pull/184). The ' + 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' + 'your code', version='2.0.0', collection_name='community.crypto') + return OpenSSLCLIBackend(module, openssl_binary=openssl_binary).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content) + + +def cryptography_get_csr_identifiers(module, csr_filename, csr_content=None): + module.deprecate( + 'Please adjust your custom module/plugin to the ACME module_utils refactor ' + '(https://github.com/ansible-collections/community.crypto/pull/184). The ' + 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' + 'your code', version='2.0.0', collection_name='community.crypto') + return CryptographyBackend(module).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content) + + +def cryptography_get_cert_days(module, cert_file, now=None): + module.deprecate( + 'Please adjust your custom module/plugin to the ACME module_utils refactor ' + '(https://github.com/ansible-collections/community.crypto/pull/184). The ' + 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' + 'your code', version='2.0.0', collection_name='community.crypto') + return CryptographyBackend(module).get_cert_days(cert_filename=cert_file, now=now) diff --git a/plugins/module_utils/acme/_compatibility.py b/plugins/module_utils/acme/_compatibility.py new file mode 100644 index 00000000..b1018338 --- /dev/null +++ b/plugins/module_utils/acme/_compatibility.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import locale + +from ansible.module_utils.basic import missing_required_lib + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import HAS_CURRENT_CRYPTOGRAPHY as _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import ( + CryptographyBackend, + CRYPTOGRAPHY_VERSION, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import ( + OpenSSLCLIBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.account import ( + ACMEAccount, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( + create_key_authorization, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + KeyParsingError, +) + + +HAS_CURRENT_CRYPTOGRAPHY = _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY + + +def set_crypto_backend(module): + ''' + Sets which crypto backend to use (default: auto detection). + + Does not care whether a new enough cryptoraphy is available or not. Must + be called before any real stuff is done which might evaluate + ``HAS_CURRENT_CRYPTOGRAPHY``. + ''' + global HAS_CURRENT_CRYPTOGRAPHY + + module.deprecate( + 'Please adjust your custom module/plugin to the ACME module_utils refactor ' + '(https://github.com/ansible-collections/community.crypto/pull/184). The ' + 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' + 'your code', version='2.0.0', collection_name='community.crypto') + + # Choose backend + backend = module.params['select_crypto_backend'] + if backend == 'auto': + pass + elif backend == 'openssl': + HAS_CURRENT_CRYPTOGRAPHY = False + elif backend == 'cryptography': + if not _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY: + module.fail_json(msg=missing_required_lib('cryptography')) + HAS_CURRENT_CRYPTOGRAPHY = True + else: + module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend)) + # Inform about choices + if HAS_CURRENT_CRYPTOGRAPHY: + module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION)) + return 'cryptography' + else: + module.debug('Using OpenSSL binary backend') + return 'openssl' + + +def handle_standard_module_arguments(module, needs_acme_v2=False): + ''' + Do standard module setup, argument handling and warning emitting. + ''' + backend = set_crypto_backend(module) + + if not module.params['validate_certs']: + module.warn( + 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.' + ) + + if module.params['acme_version'] is None: + module.params['acme_version'] = 1 + module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on", + version='2.0.0', collection_name='community.crypto') + + if module.params['acme_directory'] is None: + module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory' + module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on", + version='2.0.0', collection_name='community.crypto') + + if needs_acme_v2 and module.params['acme_version'] < 2: + module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name)) + + # AnsibleModule() changes the locale, so change it back to C because we rely on time.strptime() when parsing certificate dates. + module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') + locale.setlocale(locale.LC_ALL, 'C') + + return backend + + +def get_compatibility_backend(module): + if HAS_CURRENT_CRYPTOGRAPHY: + return CryptographyBackend(module) + else: + return OpenSSLCLIBackend(module) + + +class ACMELegacyAccount(object): + ''' + ACME account object. Handles the authorized communication with the + ACME server. Provides access to account bound information like + the currently active authorizations and valid certificates + ''' + + def __init__(self, module): + module.deprecate( + 'Please adjust your custom module/plugin to the ACME module_utils refactor ' + '(https://github.com/ansible-collections/community.crypto/pull/184). The ' + 'compatibility layer will be removed in community.crypto 2.0.0, thus breaking ' + 'your code', version='2.0.0', collection_name='community.crypto') + backend = get_compatibility_backend(module) + self.client = ACMEClient(module, backend) + self.account = ACMEAccount(self.client) + self.key = self.client.account_key_file + self.key_content = self.client.account_key_content + self.uri = self.client.account_uri + self.key_data = self.client.account_key_data + self.jwk = self.client.account_jwk + self.jws_header = self.client.account_jws_header + self.directory = self.client.directory + + def get_keyauthorization(self, token): + ''' + Returns the key authorization for the given token + https://tools.ietf.org/html/rfc8555#section-8.1 + ''' + return create_key_authorization(self.client, token) + + def parse_key(self, key_file=None, key_content=None): + ''' + Parses an RSA or Elliptic Curve key file in PEM format and returns a pair + (error, key_data). + ''' + try: + return None, self.client.parse_key(key_file=key_file, key_content=key_content) + except KeyParsingError as e: + return e.msg, None + + def sign_request(self, protected, payload, key_data, encode_payload=True): + return self.client.sign_request(protected, payload, key_data, encode_payload=encode_payload) + + def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True): + ''' + Sends a JWS signed HTTP POST request to the ACME server and returns + the response as dictionary + https://tools.ietf.org/html/rfc8555#section-6.2 + + If payload is None, a POST-as-GET is performed. + (https://tools.ietf.org/html/rfc8555#section-6.3) + ''' + return self.client.send_signed_request( + url, + payload, + key_data=key_data, + jws_header=jws_header, + parse_json_result=parse_json_result, + encode_payload=encode_payload, + fail_on_error=False, + ) + + def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True): + ''' + Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback + to GET if server replies with a status code of 405. + ''' + return self.client.get_request( + uri, + parse_json_result=parse_json_result, + headers=headers, + get_only=get_only, + fail_on_error=fail_on_error, + ) + + def set_account_uri(self, uri): + ''' + Set account URI. For ACME v2, it needs to be used to sending signed + requests. + ''' + self.client.set_account_uri(uri) + self.uri = self.client.account_uri + + def get_account_data(self): + ''' + Retrieve account information. Can only be called when the account + URI is already known (such as after calling setup_account). + Return None if the account was deactivated, or a dict otherwise. + ''' + return self.account.get_account_data() + + def setup_account(self, contact=None, agreement=None, terms_agreed=False, + allow_creation=True, remove_account_uri_if_not_exists=False, + external_account_binding=None): + ''' + Detect or create an account on the ACME server. For ACME v1, + as the only way (without knowing an account URI) to test if an + account exists is to try and create one with the provided account + key, this method will always result in an account being present + (except on error situations). For ACME v2, a new account will + only be created if ``allow_creation`` is set to True. + + For ACME v2, ``check_mode`` is fully respected. For ACME v1, the + account might be created if it does not yet exist. + + Return a pair ``(created, account_data)``. Here, ``created`` will + be ``True`` in case the account was created or would be created + (check mode). ``account_data`` will be the current account data, + or ``None`` if the account does not exist. + + The account URI will be stored in ``self.uri``; if it is ``None``, + the account does not exist. + + If specified, ``external_account_binding`` should be a dictionary + with keys ``kid``, ``alg`` and ``key`` + (https://tools.ietf.org/html/rfc8555#section-7.3.4). + + https://tools.ietf.org/html/rfc8555#section-7.3 + ''' + result = self.account.setup_account( + contact=contact, + agreement=agreement, + terms_agreed=terms_agreed, + allow_creation=allow_creation, + remove_account_uri_if_not_exists=remove_account_uri_if_not_exists, + external_account_binding=external_account_binding, + ) + self.uri = self.client.account_uri + return result + + def update_account(self, account_data, contact=None): + ''' + Update an account on the ACME server. Check mode is fully respected. + + The current account data must be provided as ``account_data``. + + Return a pair ``(updated, account_data)``, where ``updated`` is + ``True`` in case something changed (contact info updated) or + would be changed (check mode), and ``account_data`` the updated + account data. + + https://tools.ietf.org/html/rfc8555#section-7.3.2 + ''' + return self.account.update_account(account_data, contact=contact) diff --git a/plugins/module_utils/acme/account.py b/plugins/module_utils/acme/account.py new file mode 100644 index 00000000..66519d10 --- /dev/null +++ b/plugins/module_utils/acme/account.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + ModuleFailException, +) + + +class ACMEAccount(object): + ''' + ACME account object. Allows to create new accounts, check for existence of accounts, + retrieve account data. + ''' + + def __init__(self, client): + # Set to true to enable logging of all signed requests + self._debug = False + + self.client = client + + 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 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 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 update_account(self, account_data, contact=None): + ''' + Update an account on the ACME server. Check mode is fully respected. + + The current account data must be provided as ``account_data``. + + Return a pair ``(updated, account_data)``, where ``updated`` is + ``True`` in case something changed (contact info updated) or + would be changed (check mode), and ``account_data`` the updated + account data. + + https://tools.ietf.org/html/rfc8555#section-7.3.2 + ''' + # Create request + update_request = {} + if contact is not None and account_data.get('contact', []) != contact: + update_request['contact'] = list(contact) + + # No change? + if not update_request: + return False, dict(account_data) + + # Apply change + if self.client.module.check_mode: + account_data = dict(account_data) + account_data.update(update_request) + else: + if self.client.version == 1: + update_request['resource'] = 'reg' + account_data, dummy = self.client.send_signed_request(self.client.account_uri, update_request) + return True, account_data diff --git a/plugins/module_utils/acme/acme.py b/plugins/module_utils/acme/acme.py new file mode 100644 index 00000000..27a8471a --- /dev/null +++ b/plugins/module_utils/acme/acme.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import copy +import datetime +import json +import locale + +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import ( + OpenSSLCLIBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import ( + CryptographyBackend, + CRYPTOGRAPHY_VERSION, + HAS_CURRENT_CRYPTOGRAPHY, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + NetworkException, + ModuleFailException, + KeyParsingError, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, +) + + +def _assert_fetch_url_success(module, response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True): + if info['status'] < 0: + raise NetworkException(msg="Failure downloading %s, %s" % (info['url'], info['msg'])) + + if (300 <= info['status'] < 400 and not allow_redirect) or \ + (400 <= info['status'] < 500 and not allow_client_error) or \ + (info['status'] >= 500 and not allow_server_error): + raise ACMEProtocolException(module, info=info, response=response) + + +def _is_failed(info, expected_status_codes=None): + if info['status'] < 200 or info['status'] >= 400: + return True + if expected_status_codes is not None and info['status'] not in expected_status_codes: + return True + return False + + +class ACMEDirectory(object): + ''' + The ACME server directory. Gives access to the available resources, + and allows to obtain a Replay-Nonce. The acme_directory URL + needs to support unauthenticated GET requests; ACME endpoints + requiring authentication are not supported. + https://tools.ietf.org/html/rfc8555#section-7.1.1 + ''' + + 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 __getitem__(self, key): + return self.directory[key] + + def get_nonce(self, resource=None): + url = self.directory_root if self.version == 1 else self.directory['newNonce'] + if resource is not None: + url = resource + dummy, info = fetch_url(self.module, url, method='HEAD') + if info['status'] not in (200, 204): + raise NetworkException("Failed to get replay-nonce, got status {0}".format(info['status'])) + return info['replay-nonce'] + + +class ACMEClient(object): + ''' + ACME client object. Handles the authorized communication with the + ACME server. + ''' + + 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'] + + # 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) + 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 set_account_uri(self, uri): + ''' + Set account URI. For ACME v2, it needs to be used to sending signed + requests. + ''' + self.account_uri = uri + if self.version != 1: + self.account_jws_header.pop('jwk') + self.account_jws_header['kid'] = self.account_uri + + def parse_key(self, key_file=None, key_content=None): + ''' + Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. + In case of an error, raises KeyParsingError. + ''' + if key_file is None and key_content is None: + raise AssertionError('One of key_file and key_content must be specified!') + error, key_data = self.backend.parse_key(key_file, key_content) + if error: + raise KeyParsingError(error) + return key_data + + 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 _log(self, msg, data=None): + ''' + Write arguments to acme.log when logging is enabled. + ''' + if self._debug: + with open('acme.log', 'ab') as f: + f.write('[{0}] {1}\n'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%s'), msg).encode('utf-8')) + if data is not None: + f.write('{0}\n\n'.format(json.dumps(data, indent=2, sort_keys=True)).encode('utf-8')) + + def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, + encode_payload=True, fail_on_error=True, error_msg=None, expected_status_codes=None): + ''' + Sends a JWS signed HTTP POST request to the ACME server and returns + the response as dictionary (if parse_json_result is True) or in raw form + (if parse_json_result is False). + https://tools.ietf.org/html/rfc8555#section-6.2 + + If payload is None, a POST-as-GET is performed. + (https://tools.ietf.org/html/rfc8555#section-6.3) + ''' + key_data = key_data or self.account_key_data + jws_header = jws_header or self.account_jws_header + failed_tries = 0 + while True: + protected = copy.deepcopy(jws_header) + protected["nonce"] = self.directory.get_nonce() + if self.version != 1: + protected["url"] = url + + self._log('URL', url) + self._log('protected', protected) + self._log('payload', payload) + data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload) + if self.version == 1: + data["header"] = jws_header.copy() + for k, v in protected.items(): + dummy = data["header"].pop(k, None) + self._log('signed request', data) + data = self.module.jsonify(data) + + headers = { + 'Content-Type': 'application/jose+json', + } + resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST') + _assert_fetch_url_success(self.module, resp, info) + result = {} + try: + content = resp.read() + except AttributeError: + content = info.pop('body', None) + + if content or not parse_json_result: + if (parse_json_result and info['content-type'].startswith('application/json')) or 400 <= info['status'] < 600: + try: + decoded_result = self.module.from_json(content.decode('utf8')) + self._log('parsed result', decoded_result) + # In case of badNonce error, try again (up to 5 times) + # (https://tools.ietf.org/html/rfc8555#section-6.7) + if all(( + 400 <= info['status'] < 600, + decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce', + failed_tries <= 5, + )): + failed_tries += 1 + continue + if parse_json_result: + result = decoded_result + else: + result = content + except ValueError: + raise NetworkException("Failed to parse the ACME response: {0} {1}".format(url, content)) + else: + result = content + + if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes): + raise ACMEProtocolException( + self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None) + return result, info + + def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, + fail_on_error=True, error_msg=None, expected_status_codes=None): + ''' + Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback + to GET if server replies with a status code of 405. + ''' + if not get_only and self.version != 1: + # Try POST-as-GET + content, info = self.send_signed_request(uri, None, parse_json_result=False, fail_on_error=False) + if info['status'] == 405: + # Instead, do unauthenticated GET + get_only = True + else: + # Do unauthenticated GET + get_only = True + + if get_only: + # Perform unauthenticated GET + resp, info = fetch_url(self.module, uri, method='GET', headers=headers) + + _assert_fetch_url_success(self.module, resp, info) + + try: + content = resp.read() + except AttributeError: + content = info.pop('body', None) + + # Process result + if parse_json_result: + result = {} + if content: + if info['content-type'].startswith('application/json'): + try: + result = self.module.from_json(content.decode('utf8')) + except ValueError: + raise NetworkException("Failed to parse the ACME response: {0} {1}".format(uri, content)) + else: + result = content + else: + result = content + + if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes): + raise ACMEProtocolException( + self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None) + return result, info + + +def get_default_argspec(): + ''' + Provides default argument spec for the options documented in the acme doc fragment. + ''' + return dict( + account_key_src=dict(type='path', aliases=['account_key']), + account_key_content=dict(type='str', no_log=True), + account_uri=dict(type='str'), + acme_directory=dict(type='str'), + acme_version=dict(type='int', choices=[1, 2]), + validate_certs=dict(type='bool', default=True), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']), + ) + + +def create_backend(module, needs_acme_v2): + backend = module.params['select_crypto_backend'] + + # Backend autodetect + if backend == 'auto': + backend = 'cryptography' if HAS_CURRENT_CRYPTOGRAPHY else 'openssl' + + # Create backend object + if backend == 'cryptography': + if not HAS_CURRENT_CRYPTOGRAPHY: + module.fail_json(msg=missing_required_lib('cryptography')) + module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION)) + module_backend = CryptographyBackend(module) + elif backend == 'openssl': + module.debug('Using OpenSSL binary backend') + module_backend = OpenSSLCLIBackend(module) + else: + module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend)) + + # Check common module parameters + if not module.params['validate_certs']: + module.warn( + 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.' + ) + + if module.params['acme_version'] is None: + module.params['acme_version'] = 1 + module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on", + version='2.0.0', collection_name='community.crypto') + + if module.params['acme_directory'] is None: + module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory' + module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on", + version='2.0.0', collection_name='community.crypto') + + if needs_acme_v2 and module.params['acme_version'] < 2: + module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name)) + + # AnsibleModule() changes the locale, so change it back to C because we rely + # on datetime.datetime.strptime() when parsing certificate dates. + locale.setlocale(locale.LC_ALL, 'C') + + return module_backend diff --git a/plugins/module_utils/acme/backend_cryptography.py b/plugins/module_utils/acme/backend_cryptography.py new file mode 100644 index 00000000..77366ca2 --- /dev/null +++ b/plugins/module_utils/acme/backend_cryptography.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import binascii +import datetime +import os +import sys + +from ansible.module_utils._text import to_bytes, to_native + +from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( + CryptoBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import ( + ChainMatcher, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import BackendException + +from ansible_collections.community.crypto.plugins.module_utils.acme.io import read_file + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 + +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, +) + +try: + import cryptography + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.hmac + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.utils + import cryptography.hazmat.primitives.serialization + import cryptography.x509 + import cryptography.x509.oid + from distutils.version import LooseVersion + CRYPTOGRAPHY_VERSION = cryptography.__version__ + HAS_CURRENT_CRYPTOGRAPHY = (LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion('1.5')) + if HAS_CURRENT_CRYPTOGRAPHY: + _cryptography_backend = cryptography.hazmat.backends.default_backend() +except Exception as dummy: + HAS_CURRENT_CRYPTOGRAPHY = False + CRYPTOGRAPHY_VERSION = None + + +if sys.version_info[0] >= 3: + # Python 3 (and newer) + def _count_bytes(n): + return (n.bit_length() + 7) // 8 if n > 0 else 0 + + def _convert_int_to_bytes(count, no): + return no.to_bytes(count, byteorder='big') + + def _pad_hex(n, digits): + res = hex(n)[2:] + if len(res) < digits: + res = '0' * (digits - len(res)) + res + return res +else: + # Python 2 + def _count_bytes(n): + if n <= 0: + return 0 + h = '%x' % n + return (len(h) + 1) // 2 + + def _convert_int_to_bytes(count, n): + h = '%x' % n + if len(h) > 2 * count: + raise Exception('Number {1} needs more than {0} bytes!'.format(count, n)) + return ('0' * (2 * count - len(h)) + h).decode('hex') + + def _pad_hex(n, digits): + h = '%x' % n + if len(h) < digits: + h = '0' * (digits - len(h)) + h + return h + + +class CryptographyChainMatcher(ChainMatcher): + @staticmethod + def _parse_key_identifier(key_identifier, name, criterium_idx, module): + if key_identifier: + try: + return binascii.unhexlify(key_identifier.replace(':', '')) + except Exception: + if criterium_idx is None: + module.warn('Criterium has invalid {0} value. Ignoring criterium.'.format(name)) + else: + module.warn('Criterium {0} in select_chain has invalid {1} value. ' + 'Ignoring criterium.'.format(criterium_idx, name)) + return None + + def __init__(self, criterium, module): + self.criterium = criterium + self.test_certificates = criterium.test_certificates + self.subject = [] + self.issuer = [] + if criterium.subject: + self.subject = [ + (cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.subject) + ] + if criterium.issuer: + self.issuer = [ + (cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.issuer) + ] + self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier( + criterium.subject_key_identifier, 'subject_key_identifier', criterium.index, module) + self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier( + criterium.authority_key_identifier, 'authority_key_identifier', criterium.index, module) + + def _match_subject(self, x509_subject, match_subject): + for oid, value in match_subject: + found = False + for attribute in x509_subject: + if attribute.oid == oid and value == to_native(attribute.value): + found = True + break + if not found: + return False + return True + + def match(self, certificate): + ''' + Check whether an alternate chain matches the specified criterium. + ''' + chain = certificate.chain + if self.test_certificates == 'last': + chain = chain[-1:] + elif self.test_certificates == 'first': + chain = chain[:1] + for cert in chain: + try: + x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend()) + matches = True + if not self._match_subject(x509.subject, self.subject): + matches = False + if not self._match_subject(x509.issuer, self.issuer): + matches = False + if self.subject_key_identifier: + try: + ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier) + if self.subject_key_identifier != ext.value.digest: + matches = False + except cryptography.x509.ExtensionNotFound: + matches = False + if self.authority_key_identifier: + try: + ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier) + if self.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 + + +class CryptographyBackend(CryptoBackend): + def __init__(self, module): + super(CryptographyBackend, self).__init__(module) + + def parse_key(self, key_file=None, key_content=None): + ''' + Parses an RSA or Elliptic Curve key file in PEM format and returns a pair + (error, key_data). + ''' + # If key_content isn't given, read key_file + if key_content is None: + key_content = read_file(key_file) + else: + key_content = to_bytes(key_content) + # Parse key + try: + key = cryptography.hazmat.primitives.serialization.load_pem_private_key(key_content, password=None, backend=_cryptography_backend) + except Exception as e: + return 'error while loading key: {0}'.format(e), None + if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + pk = key.public_key().public_numbers() + return None, { + 'key_obj': key, + 'type': 'rsa', + 'alg': 'RS256', + 'jwk': { + "kty": "RSA", + "e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)), + "n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)), + }, + 'hash': 'sha256', + } + elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + pk = key.public_key().public_numbers() + if pk.curve.name == 'secp256r1': + bits = 256 + alg = 'ES256' + hashalg = 'sha256' + point_size = 32 + curve = 'P-256' + elif pk.curve.name == 'secp384r1': + bits = 384 + alg = 'ES384' + hashalg = 'sha384' + point_size = 48 + curve = 'P-384' + elif pk.curve.name == 'secp521r1': + # Not yet supported on Let's Encrypt side, see + # https://github.com/letsencrypt/boulder/issues/2217 + bits = 521 + alg = 'ES512' + hashalg = 'sha512' + point_size = 66 + curve = 'P-521' + else: + return 'unknown elliptic curve: {0}'.format(pk.curve.name), {} + num_bytes = (bits + 7) // 8 + return None, { + 'key_obj': key, + 'type': 'ec', + 'alg': alg, + 'jwk': { + "kty": "EC", + "crv": curve, + "x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)), + "y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)), + }, + 'hash': hashalg, + 'point_size': point_size, + } + else: + return 'unknown key type "{0}"'.format(type(key)), {} + + def sign(self, payload64, protected64, key_data): + sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + if 'mac_obj' in key_data: + mac = key_data['mac_obj']() + mac.update(sign_payload) + signature = mac.finalize() + elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15() + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + signature = key_data['key_obj'].sign(sign_payload, padding, hashalg()) + elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + if key_data['hash'] == 'sha256': + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + elif key_data['hash'] == 'sha384': + hashalg = cryptography.hazmat.primitives.hashes.SHA384 + elif key_data['hash'] == 'sha512': + hashalg = cryptography.hazmat.primitives.hashes.SHA512 + ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg()) + r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(key_data['key_obj'].sign(sign_payload, ecdsa)) + rr = _pad_hex(r, 2 * key_data['point_size']) + ss = _pad_hex(s, 2 * key_data['point_size']) + signature = binascii.unhexlify(rr) + binascii.unhexlify(ss) + + return { + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(signature), + } + + def create_mac_key(self, alg, key): + '''Create a MAC key.''' + if alg == 'HS256': + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + hashbytes = 32 + elif alg == 'HS384': + hashalg = cryptography.hazmat.primitives.hashes.SHA384 + hashbytes = 48 + elif alg == 'HS512': + hashalg = cryptography.hazmat.primitives.hashes.SHA512 + hashbytes = 64 + else: + raise BackendException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg)) + key_bytes = base64.urlsafe_b64decode(key) + if len(key_bytes) < hashbytes: + raise BackendException( + '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) + return { + 'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC( + key_bytes, + hashalg(), + _cryptography_backend), + 'type': 'hmac', + 'alg': alg, + 'jwk': { + 'kty': 'oct', + 'k': key, + }, + } + + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + identifiers = set([]) + if csr_content is None: + csr_content = read_file(csr_filename) + else: + csr_content = to_bytes(csr_content) + csr = cryptography.x509.load_pem_x509_csr(csr_content, _cryptography_backend) + for sub in csr.subject: + if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME: + identifiers.add(('dns', sub.value)) + for extension in csr.extensions: + if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: + for name in extension.value: + if isinstance(name, cryptography.x509.DNSName): + identifiers.add(('dns', name.value)) + elif isinstance(name, cryptography.x509.IPAddress): + identifiers.add(('ip', name.value.compressed)) + else: + raise BackendException('Found unsupported SAN identifier {0}'.format(name)) + return identifiers + + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + ''' + Return the days the certificate in cert_filename remains valid and -1 + if the file was not found. If cert_filename contains more than one + certificate, only the first one will be considered. + + If now is not specified, datetime.datetime.now() is used. + ''' + if cert_filename is not None: + cert_content = None + if os.path.exists(cert_filename): + cert_content = read_file(cert_filename) + else: + cert_content = to_bytes(cert_content) + + if cert_content is None: + return -1 + + try: + cert = cryptography.x509.load_pem_x509_certificate(cert_content, _cryptography_backend) + except Exception as e: + if cert_filename is None: + raise BackendException('Cannot parse certificate: {0}'.format(e)) + raise BackendException('Cannot parse certificate {0}: {1}'.format(cert_filename, e)) + + if now is None: + now = datetime.datetime.now() + return (cert.not_valid_after - now).days + + def create_chain_matcher(self, criterium): + ''' + Given a Criterium object, creates a ChainMatcher object. + ''' + return CryptographyChainMatcher(criterium, self.module) diff --git a/plugins/module_utils/acme/backend_openssl_cli.py b/plugins/module_utils/acme/backend_openssl_cli.py new file mode 100644 index 00000000..fdbe6cb5 --- /dev/null +++ b/plugins/module_utils/acme/backend_openssl_cli.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import binascii +import datetime +import os +import re +import tempfile +import traceback + +from ansible.module_utils._text import to_native, to_text, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( + CryptoBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + BackendException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 + +from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress + + +_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') + + +class OpenSSLCLIBackend(CryptoBackend): + def __init__(self, module, openssl_binary=None): + super(OpenSSLCLIBackend, self).__init__(module) + if openssl_binary is None: + openssl_binary = module.get_bin_path('openssl', True) + self.openssl_binary = openssl_binary + + def parse_key(self, key_file=None, key_content=None): + ''' + Parses an RSA or Elliptic Curve key file in PEM format and returns a pair + (error, key_data). + ''' + # If key_file isn't given, but key_content, write that to a temporary file + if key_file is None: + fd, tmpsrc = tempfile.mkstemp() + self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit + f = os.fdopen(fd, 'wb') + try: + f.write(key_content.encode('utf-8')) + key_file = tmpsrc + except Exception as err: + try: + f.close() + except Exception as dummy: + pass + raise BackendException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) + f.close() + # Parse key + account_key_type = None + with open(key_file, "rt") as f: + for line in f: + m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line) + if m is not None: + account_key_type = m.group(1).lower() + break + if account_key_type is None: + # This happens for example if openssl_privatekey created this key + # (as opposed to the OpenSSL binary). For now, we assume this is + # an RSA key. + # FIXME: add some kind of auto-detection + account_key_type = "rsa" + if account_key_type not in ("rsa", "ec"): + return 'unknown key type "%s"' % account_key_type, {} + + openssl_keydump_cmd = [self.openssl_binary, account_key_type, "-in", key_file, "-noout", "-text"] + dummy, out, dummy = self.module.run_command( + openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + + if account_key_type == 'rsa': + pub_hex, pub_exp = re.search( + r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups() + pub_exp = "{0:x}".format(int(pub_exp)) + if len(pub_exp) % 2: + pub_exp = "0{0}".format(pub_exp) + + return None, { + 'key_file': key_file, + 'type': 'rsa', + 'alg': 'RS256', + 'jwk': { + "kty": "RSA", + "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), + "n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), + }, + 'hash': 'sha256', + } + elif account_key_type == 'ec': + pub_data = re.search( + r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + if pub_data is None: + return 'cannot parse elliptic curve key', {} + pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8")) + asn1_oid_curve = pub_data.group(2).lower() + nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None + if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256': + bits = 256 + alg = 'ES256' + hashalg = 'sha256' + point_size = 32 + curve = 'P-256' + elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384': + bits = 384 + alg = 'ES384' + hashalg = 'sha384' + point_size = 48 + curve = 'P-384' + elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521': + # Not yet supported on Let's Encrypt side, see + # https://github.com/letsencrypt/boulder/issues/2217 + bits = 521 + alg = 'ES512' + hashalg = 'sha512' + point_size = 66 + curve = 'P-521' + else: + return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {} + num_bytes = (bits + 7) // 8 + if len(pub_hex) != 2 * num_bytes: + return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {} + return None, { + 'key_file': key_file, + 'type': 'ec', + 'alg': alg, + 'jwk': { + "kty": "EC", + "crv": curve, + "x": nopad_b64(pub_hex[:num_bytes]), + "y": nopad_b64(pub_hex[num_bytes:]), + }, + 'hash': hashalg, + 'point_size': point_size, + } + + def sign(self, payload64, protected64, key_data): + sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + if key_data['type'] == 'hmac': + hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k']))) + cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"] + else: + cmd_postfix = ["-sign", key_data['key_file']] + openssl_sign_cmd = [self.openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix + + dummy, out, dummy = self.module.run_command( + openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + + if key_data['type'] == 'ec': + dummy, der_out, dummy = self.module.run_command( + [self.openssl_binary, "asn1parse", "-inform", "DER"], + data=out, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + expected_len = 2 * key_data['point_size'] + sig = re.findall( + r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, + to_text(der_out, errors='surrogate_or_strict')) + if len(sig) != 2: + raise BackendException( + "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( + to_text(der_out, errors='surrogate_or_strict'))) + sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] + sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] + out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) + + return { + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(to_bytes(out)), + } + + def create_mac_key(self, alg, key): + '''Create a MAC key.''' + if alg == 'HS256': + hashalg = 'sha256' + hashbytes = 32 + elif alg == 'HS384': + hashalg = 'sha384' + hashbytes = 48 + elif alg == 'HS512': + hashalg = 'sha512' + hashbytes = 64 + else: + raise BackendException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg)) + key_bytes = base64.urlsafe_b64decode(key) + if len(key_bytes) < hashbytes: + raise BackendException( + '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) + return { + 'type': 'hmac', + 'alg': alg, + 'jwk': { + 'kty': 'oct', + 'k': key, + }, + 'hash': hashalg, + } + + @staticmethod + def _normalize_ip(ip): + try: + return to_native(compat_ipaddress.ip_address(to_text(ip)).compressed) + except ValueError: + # We don't want to error out on something IPAddress() can't parse + return ip + + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + filename = csr_filename + data = None + if csr_content is not None: + filename = '-' + data = csr_content.encode('utf-8') + + openssl_csr_cmd = [self.openssl_binary, "req", "-in", filename, "-noout", "-text"] + dummy, out, dummy = self.module.run_command( + openssl_csr_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + + identifiers = set([]) + common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict')) + if common_name is not None: + identifiers.add(('dns', common_name.group(1))) + subject_alt_names = re.search( + r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.lower().startswith("dns:"): + identifiers.add(('dns', san[4:])) + elif san.lower().startswith("ip:"): + identifiers.add(('ip', self._normalize_ip(san[3:]))) + elif san.lower().startswith("ip address:"): + identifiers.add(('ip', self._normalize_ip(san[11:]))) + else: + raise BackendException('Found unsupported SAN identifier "{0}"'.format(san)) + return identifiers + + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + ''' + Return the days the certificate in cert_filename remains valid and -1 + if the file was not found. If cert_filename contains more than one + certificate, only the first one will be considered. + + If now is not specified, datetime.datetime.now() is used. + ''' + filename = cert_filename + data = None + if cert_content is not None: + filename = '-' + data = cert_content.encode('utf-8') + cert_filename_suffix = '' + elif cert_filename is not None: + if not os.path.exists(cert_filename): + return -1 + cert_filename_suffix = ' in {0}'.format(cert_filename) + else: + return -1 + + openssl_cert_cmd = [self.openssl_binary, "x509", "-in", filename, "-noout", "-text"] + dummy, out, dummy = self.module.run_command( + openssl_cert_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + try: + not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", to_text(out, errors='surrogate_or_strict')).group(1) + not_after = datetime.datetime.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z') + except AttributeError: + raise BackendException("No 'Not after' date found{0}".format(cert_filename_suffix)) + except ValueError: + raise BackendException("Failed to parse 'Not after' date{0}".format(cert_filename_suffix)) + if now is None: + now = datetime.datetime.now() + return (not_after - now).days + + def create_chain_matcher(self, criterium): + ''' + Given a Criterium object, creates a ChainMatcher object. + ''' + raise BackendException('Alternate chain matching can only be used with the "cryptography" backend.') diff --git a/plugins/module_utils/acme/backends.py b/plugins/module_utils/acme/backends.py new file mode 100644 index 00000000..afd364c3 --- /dev/null +++ b/plugins/module_utils/acme/backends.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import abc + +from ansible.module_utils import six + + +@six.add_metaclass(abc.ABCMeta) +class CryptoBackend(object): + def __init__(self, module): + self.module = module + + @abc.abstractmethod + def parse_key(self, key_file=None, key_content=None): + ''' + Parses an RSA or Elliptic Curve key file in PEM format and returns a pair + (error, key_data). + ''' + + @abc.abstractmethod + def sign(self, payload64, protected64, key_data): + pass + + @abc.abstractmethod + def create_mac_key(self, alg, key): + '''Create a MAC key.''' + + @abc.abstractmethod + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + + @abc.abstractmethod + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + ''' + Return the days the certificate in cert_filename remains valid and -1 + if the file was not found. If cert_filename contains more than one + certificate, only the first one will be considered. + + If now is not specified, datetime.datetime.now() is used. + ''' + + @abc.abstractmethod + def create_chain_matcher(self, criterium): + ''' + Given a Criterium object, creates a ChainMatcher object. + ''' diff --git a/plugins/module_utils/acme/certificates.py b/plugins/module_utils/acme/certificates.py new file mode 100644 index 00000000..bafe8722 --- /dev/null +++ b/plugins/module_utils/acme/certificates.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import abc + +from ansible.module_utils import six + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ModuleFailException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + der_to_pem, + nopad_b64, + process_links, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( + split_pem_list, +) + + +class CertificateChain(object): + ''' + Download and parse the certificate chain. + https://tools.ietf.org/html/rfc8555#section-7.4.2 + ''' + + def __init__(self, url): + self.url = url + self.cert = None + self.chain = [] + self.alternates = [] + + @classmethod + 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 _process_links(self, client, link, relation): + if relation == 'up': + # Process link-up headers if there was no chain in reply + if not self.chain: + chain_result, chain_info = client.get_request(link, parse_json_result=False) + if chain_info['status'] in [200, 201]: + self.chain.append(der_to_pem(chain_result)) + elif relation == 'alternate': + self.alternates.append(link) + + def to_json(self): + cert = self.cert.encode('utf8') + chain = ('\n'.join(self.chain)).encode('utf8') + return { + 'cert': cert, + 'chain': chain, + 'full_chain': cert + chain, + } + + +class Criterium(object): + def __init__(self, criterium, index=None): + self.index = index + self.test_certificates = criterium['test_certificates'] + self.subject = criterium['subject'] + self.issuer = criterium['issuer'] + self.subject_key_identifier = criterium['subject_key_identifier'] + self.authority_key_identifier = criterium['authority_key_identifier'] + + +@six.add_metaclass(abc.ABCMeta) +class ChainMatcher(object): + @abc.abstractmethod + def match(self, certificate): + ''' + Check whether a certificate chain (CertificateChain instance) matches. + ''' + + +def retrieve_acme_v1_certificate(client, csr_der): + ''' + 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 + ''' + new_cert = { + "resource": "new-cert", + "csr": nopad_b64(csr_der), + } + result, info = client.send_signed_request( + client.directory['new-cert'], new_cert, error_msg='Failed to receive certificate', expected_status_codes=[200, 201]) + cert = CertificateChain(info['location']) + cert.cert = der_to_pem(result) + + def f(link, relation): + if relation == 'up': + chain_result, chain_info = client.get_request(link, parse_json_result=False) + if chain_info['status'] in [200, 201]: + del cert.chain[:] + cert.chain.append(der_to_pem(chain_result)) + + process_links(info, f) + return cert diff --git a/plugins/module_utils/acme/challenges.py b/plugins/module_utils/acme/challenges.py new file mode 100644 index 00000000..73db18ac --- /dev/null +++ b/plugins/module_utils/acme/challenges.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import hashlib +import json +import re +import time + +from ansible.module_utils._text import to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + format_error_problem, + ACMEProtocolException, + ModuleFailException, +) + + +def create_key_authorization(client, token): + ''' + Returns the key authorization for the given token + https://tools.ietf.org/html/rfc8555#section-8.1 + ''' + accountkey_json = json.dumps(client.account_jwk, sort_keys=True, separators=(',', ':')) + thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + return "{0}.{1}".format(token, thumbprint) + + +def combine_identifier(identifier_type, identifier): + return '{type}:{identifier}'.format(type=identifier_type, identifier=identifier) + + +def split_identifier(identifier): + parts = identifier.split(':', 1) + if len(parts) != 2: + raise ModuleFailException( + 'Identifier "{identifier}" is not of the form :'.format(identifier=identifier)) + return parts + + +class Challenge(object): + def __init__(self, data, url): + self.data = data + + self.type = data['type'] + self.url = url + self.status = data['status'] + self.token = data.get('token') + + @classmethod + def from_json(cls, client, data, url=None): + return cls(data, url or (data['uri'] if client.version == 1 else data['url'])) + + def call_validate(self, client): + challenge_response = {} + if client.version == 1: + token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token) + key_authorization = create_key_authorization(client, token) + challenge_response['resource'] = 'challenge' + challenge_response['keyAuthorization'] = key_authorization + challenge_response['type'] = self.type + client.send_signed_request( + self.url, + challenge_response, + error_msg='Failed to validate challenge', + expected_status_codes=[200, 202], + ) + + def to_json(self): + return self.data.copy() + + def get_validation_data(self, client, identifier_type, identifier): + token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token) + key_authorization = create_key_authorization(client, token) + + if self.type == 'http-01': + # https://tools.ietf.org/html/rfc8555#section-8.3 + return { + 'resource': '.well-known/acme-challenge/{token}'.format(token=token), + 'resource_value': key_authorization, + } + + if self.type == 'dns-01': + if identifier_type != 'dns': + return None + # https://tools.ietf.org/html/rfc8555#section-8.4 + resource = '_acme-challenge' + value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest()) + record = (resource + identifier[1:]) if identifier.startswith('*.') else '{0}.{1}'.format(resource, identifier) + return { + 'resource': resource, + 'resource_value': value, + 'record': record, + } + + if self.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(key_authorization)).digest()) + return { + 'resource': resource, + 'resource_original': combine_identifier(identifier_type, identifier), + 'resource_value': value, + } + + # Unknown challenge type: ignore + return None + + +class Authorization(object): + def _setup(self, client, data): + data['uri'] = self.url + self.data = data + self.challenges = [Challenge.from_json(client, challenge) for challenge in data['challenges']] + if client.version == 1 and 'status' not in data: + # 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"." + self.status = 'pending' + else: + self.status = data['status'] + self.identifier = data['identifier']['value'] + self.identifier_type = data['identifier']['type'] + if data.get('wildcard', False): + self.identifier = '*.{0}'.format(self.identifier) + + def __init__(self, url): + self.url = url + + self.data = None + self.challenges = [] + self.status = None + self.identifier_type = None + self.identifier = None + + @classmethod + def from_json(cls, client, data, url): + result = cls(url) + result._setup(client, data) + return result + + @classmethod + def from_url(cls, client, url): + result = cls(url) + result.refresh(client) + return result + + @classmethod + def create(cls, client, 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 = { + "identifier": { + "type": identifier_type, + "value": identifier, + }, + } + if client.version == 1: + url = client.directory['new-authz'] + new_authz["resource"] = "new-authz" + else: + if 'newAuthz' not in client.directory.directory: + raise ACMEProtocolException('ACME endpoint does not support pre-authorization') + url = client.directory['newAuthz'] + + result, info = client.send_signed_request( + url, new_authz, error_msg='Failed to request challenges', expected_status_codes=[200, 201]) + return cls.from_json(client, result, info['location']) + + @property + def combined_identifier(self): + return combine_identifier(self.identifier_type, self.identifier) + + def to_json(self): + return self.data.copy() + + def refresh(self, client): + result, dummy = client.get_request(self.url) + changed = self.data != result + self._setup(client, result) + return changed + + def get_challenge_data(self, client): + ''' + Returns a dict with the data for all proposed (and supported) challenges + of the given authorization. + ''' + data = {} + for challenge in self.challenges: + validation_data = challenge.get_validation_data(client, self.identifier_type, self.identifier) + if validation_data is not None: + data[challenge.type] = validation_data + return data + + def raise_error(self, error_msg): + ''' + 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 self.challenges: + if challenge.status == 'invalid': + msg = 'Challenge {type}'.format(type=challenge.type) + if 'error' in challenge.data: + msg = '{msg}: {problem}'.format( + msg=msg, + problem=format_error_problem(challenge.data['error'], subproblem_prefix='{0}.'.format(type)), + ) + error_details.append(msg) + raise ACMEProtocolException( + 'Failed to validate challenge for {identifier}: {error}. {details}'.format( + identifier=self.combined_identifier, + error=error_msg, + details='; '.join(error_details), + ), + identifier=self.combined_identifier, + authorization=self.data, + ) + + def find_challenge(self, challenge_type): + for challenge in self.challenges: + if challenge_type == challenge.type: + return challenge + return None + + def wait_for_validation(self, client, callenge_type): + while True: + self.refresh(client) + if self.status in ['valid', 'invalid', 'revoked']: + break + time.sleep(2) + + if self.status == 'invalid': + self.raise_error('Status is "invalid"') + + return self.status == 'valid' + + 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 deactivate(self, client): + ''' + Deactivates this authorization. + https://community.letsencrypt.org/t/authorization-deactivation/19860/2 + https://tools.ietf.org/html/rfc8555#section-7.5.2 + ''' + if self.status != 'valid': + return + authz_deactivate = { + 'status': 'deactivated' + } + if client.version == 1: + authz_deactivate['resource'] = 'authz' + result, info = client.send_signed_request(self.url, authz_deactivate, fail_on_error=False) + if 200 <= info['status'] < 300 and result.get('status') == 'deactivated': + self.status = 'deactivated' + return True + return False diff --git a/plugins/module_utils/acme/errors.py b/plugins/module_utils/acme/errors.py new file mode 100644 index 00000000..1ee767c9 --- /dev/null +++ b/plugins/module_utils/acme/errors.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +def format_error_problem(problem, subproblem_prefix=''): + if 'title' in problem: + msg = 'Error "{title}" ({type})'.format( + type=problem['type'], + title=problem['title'], + ) + else: + msg = 'Error {type}'.format(type=problem['type']) + if 'detail' in problem: + msg += ': "{detail}"'.format(detail=problem['detail']) + subproblems = problem.get('subproblems') + if subproblems is not None: + msg = '{msg} Subproblems:'.format(msg=msg) + for index, problem in enumerate(subproblems): + index_str = '{prefix}{index}'.format(prefix=subproblem_prefix, index=index) + msg = '{msg}\n({index}) {problem}.'.format( + msg=msg, + index=index_str, + problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index_str)), + ) + return msg + + +class ModuleFailException(Exception): + ''' + If raised, module.fail_json() will be called with the given parameters after cleanup. + ''' + def __init__(self, msg, **args): + super(ModuleFailException, self).__init__(self, msg) + self.msg = msg + self.module_fail_args = args + + def do_fail(self, module, **arguments): + module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments) + + +class ACMEProtocolException(ModuleFailException): + def __init__(self, module, msg=None, info=None, response=None, content=None, content_json=None): + # Try to get hold of content, if response is given and content is not provided + if content is None and content_json is None and response is not None: + try: + content = response.read() + except AttributeError: + content = info.pop('body', None) + + # Try to get hold of JSON decoded content, when content is given and JSON not provided + if content_json is None and content is not None: + try: + content_json = module.from_json(content.decode('utf8')) + except Exception: + pass + + extras = dict() + url = info['url'] if info else None + code = info['status'] if info else None + extras['http_url'] = url + extras['http_status'] = code + + if msg is None: + msg = 'ACME request failed' + add_msg = '' + + if code >= 400 and content_json is not None and 'type' in content_json: + if 'status' in content_json and content_json['status'] != code: + code = 'status {problem_code} (HTTP status: {http_code})'.format(http_code=code, problem_code=content_json['status']) + else: + code = 'status {problem_code}'.format(problem_code=code) + add_msg = ' {problem}.'.format(problem=format_error_problem(content_json)) + + subproblems = content_json.pop('subproblems', None) + extras['problem'] = content_json + extras['subproblems'] = subproblems or [] + if subproblems is not None: + add_msg = '{add_msg} Subproblems:'.format(add_msg=add_msg) + for index, problem in enumerate(subproblems): + add_msg = '{add_msg}\n({index}) {problem}.'.format( + add_msg=add_msg, + index=index, + problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index)), + ) + else: + code = 'HTTP status {code}'.format(code=code) + if content_json is not None: + add_msg = ' The JSON error result: {content}'.format(content=content_json) + elif content is not None: + add_msg = ' The raw error result: {content}'.format(content=content.decode('utf-8')) + + super(ACMEProtocolException, self).__init__( + '{msg} for {url} with {code}.{add_msg}'.format(msg=msg, url=url, code=code, add_msg=add_msg), + **extras + ) + self.problem = {} + self.subproblems = [] + for k, v in extras.items(): + setattr(self, k, v) + + +class BackendException(ModuleFailException): + pass + + +class NetworkException(ModuleFailException): + pass + + +class KeyParsingError(ModuleFailException): + pass diff --git a/plugins/module_utils/acme/io.py b/plugins/module_utils/acme/io.py new file mode 100644 index 00000000..06464a3c --- /dev/null +++ b/plugins/module_utils/acme/io.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2013, Romeo Theriault +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import os +import shutil +import tempfile +import traceback + +from ansible.module_utils._text import to_native + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +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)) + + +# This function was adapted from an earlier version of https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/uri.py +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 diff --git a/plugins/module_utils/acme/orders.py b/plugins/module_utils/acme/orders.py new file mode 100644 index 00000000..996ff18f --- /dev/null +++ b/plugins/module_utils/acme/orders.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import time + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( + Authorization, +) + + +class Order(object): + def _setup(self, client, data): + self.data = data + + self.status = data['status'] + self.identifiers = [] + for identifier in data['identifiers']: + self.identifiers.append((identifier['type'], identifier['value'])) + self.finalize_uri = data.get('finalize') + self.certificate_uri = data.get('certificate') + self.authorization_uris = data['authorizations'] + self.authorizations = {} + + def __init__(self, url): + self.url = url + + self.data = None + + self.status = None + self.identifiers = [] + self.finalize_uri = None + self.certificate_uri = None + self.authorization_uris = [] + self.authorizations = {} + + @classmethod + def from_json(cls, client, data, url): + result = cls(url) + result._setup(client, data) + return result + + @classmethod + def from_url(cls, client, url): + result = cls(url) + result.refresh(client) + return result + + @classmethod + def create(cls, client, identifiers): + ''' + Start a new certificate order (ACME v2 protocol). + https://tools.ietf.org/html/rfc8555#section-7.4 + ''' + acme_identifiers = [] + for identifier_type, identifier in identifiers: + acme_identifiers.append({ + 'type': identifier_type, + 'value': identifier, + }) + new_order = { + "identifiers": acme_identifiers + } + result, info = client.send_signed_request( + client.directory['newOrder'], new_order, error_msg='Failed to start new order', expected_status_codes=[201]) + return cls.from_json(client, result, info['location']) + + def refresh(self, client): + result, dummy = client.get_request(self.url) + changed = self.data != result + self._setup(client, result) + return changed + + def load_authorizations(self, client): + for auth_uri in self.authorization_uris: + authz = Authorization.from_url(client, auth_uri) + self.authorizations[authz.combined_identifier] = authz + + def wait_for_finalization(self, client): + while True: + self.refresh(client) + if self.status in ['valid', 'invalid', 'pending', 'ready']: + break + time.sleep(2) + + if self.status != 'valid': + raise ACMEProtocolException( + 'Failed to wait for order to complete; got status "{status}"'.format(status=self.status), content_json=self.data) + + def finalize(self, client, csr_der, wait=True): + ''' + Create a new certificate based on the csr. + Return the certificate object as dict + https://tools.ietf.org/html/rfc8555#section-7.4 + ''' + new_cert = { + "csr": nopad_b64(csr_der), + } + result, info = client.send_signed_request( + self.finalize_uri, new_cert, error_msg='Failed to finalizing order', expected_status_codes=[200]) + # It is not clear from the RFC whether the finalize call returns the order object or not. + # Instead of using the result, we call self.refresh(client) below. + + if wait: + self.wait_for_finalization(client) + else: + self.refresh(client) + if self.status not in ['procesing', 'valid', 'invalid']: + raise ACMEProtocolException( + 'Failed to finalize order; got status "{status}"'.format( + status=self.status), info=info, content_json=result) diff --git a/plugins/module_utils/acme/utils.py b/plugins/module_utils/acme/utils.py new file mode 100644 index 00000000..3ccb9f92 --- /dev/null +++ b/plugins/module_utils/acme/utils.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016 Michael Gruener +# Copyright: (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import re +import textwrap +import traceback + +from ansible.module_utils._text import to_native +from ansible.module_utils.six.moves.urllib.parse import unquote + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +def nopad_b64(data): + return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "") + + +def der_to_pem(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 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 process_links(info, callback): + ''' + Process link header, calls callback for every link header with the URL and relation as options. + ''' + if 'link' in info: + link = info['link'] + for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link): + callback(unquote(url), relation) diff --git a/plugins/modules/acme_account.py b/plugins/modules/acme_account.py index addac552..814f690a 100644 --- a/plugins/modules/acme_account.py +++ b/plugins/modules/acme_account.py @@ -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'] = { diff --git a/plugins/modules/acme_account_info.py b/plugins/modules/acme_account_info.py index 5326f166..c24aae8d 100644 --- a/plugins/modules/acme_account_info.py +++ b/plugins/modules/acme_account_info.py @@ -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) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index f1d233b4..0eb16efb 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -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, diff --git a/plugins/modules/acme_certificate_revoke.py b/plugins/modules/acme_certificate_revoke.py index d9751abd..fa33f81b 100644 --- a/plugins/modules/acme_certificate_revoke.py +++ b/plugins/modules/acme_certificate_revoke.py @@ -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) diff --git a/plugins/modules/acme_challenge_cert_helper.py b/plugins/modules/acme_challenge_cert_helper.py index 344044bc..d18c3dec 100644 --- a/plugins/modules/acme_challenge_cert_helper.py +++ b/plugins/modules/acme_challenge_cert_helper.py @@ -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, ) diff --git a/plugins/modules/acme_inspect.py b/plugins/modules/acme_inspect.py index c22131c4..a2a4f067 100644 --- a/plugins/modules/acme_inspect.py +++ b/plugins/modules/acme_inspect.py @@ -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: diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 43335011..b9934b06 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -1,3 +1,4 @@ +plugins/module_utils/acme/__init__.py empty-init plugins/module_utils/compat/ipaddress.py future-import-boilerplate plugins/module_utils/compat/ipaddress.py metaclass-boilerplate plugins/module_utils/compat/ipaddress.py no-assert diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 43335011..b9934b06 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -1,3 +1,4 @@ +plugins/module_utils/acme/__init__.py empty-init plugins/module_utils/compat/ipaddress.py future-import-boilerplate plugins/module_utils/compat/ipaddress.py metaclass-boilerplate plugins/module_utils/compat/ipaddress.py no-assert diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index f12ed5f7..f0988358 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -1,3 +1,4 @@ +plugins/module_utils/acme/__init__.py empty-init plugins/module_utils/compat/ipaddress.py future-import-boilerplate plugins/module_utils/compat/ipaddress.py metaclass-boilerplate plugins/module_utils/compat/ipaddress.py no-assert diff --git a/tests/unit/plugins/module_utils/acme/backend_data.py b/tests/unit/plugins/module_utils/acme/backend_data.py new file mode 100644 index 00000000..432d9afa --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/backend_data.py @@ -0,0 +1,76 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import datetime +import os + + +def load_fixture(name): + with open(os.path.join(os.path.dirname(__file__), 'fixtures', name)) as f: + return f.read() + + +TEST_PEM_DERS = [ + ( + load_fixture('privatekey_1.pem'), + base64.b64decode('MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAo' + 'GCCqGSM49AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3' + 'lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw==') + ) +] + + +TEST_KEYS = [ + ( + load_fixture('privatekey_1.pem'), + { + 'alg': 'ES256', + 'hash': 'sha256', + 'jwk': { + 'crv': 'P-256', + 'kty': 'EC', + 'x': 'AJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68E', + 'y': 'vEEs4V0egJkNyM2Q4pp001zu14VcpQ0_Ei8xOOPxKZs', + }, + 'point_size': 32, + 'type': 'ec', + }, + load_fixture('privatekey_1.txt'), + ) +] + + +TEST_CSRS = [ + ( + load_fixture('csr_1.pem'), + set([ + ('dns', 'ansible.com'), + ('dns', 'example.com'), + ('dns', 'example.org') + ]), + load_fixture('csr_1.txt'), + ), + ( + load_fixture('csr_2.pem'), + set([ + ('dns', 'ansible.com'), + ('ip', '127.0.0.1'), + ('ip', '::1'), + ('ip', '2001:d88:ac10:fe01::'), + ('ip', '2001:1234:5678:abcd:9876:5432:10fe:dcba') + ]), + load_fixture('csr_2.txt'), + ), +] + + +TEST_CERT = load_fixture("cert_1.pem") + + +TEST_CERT_DAYS = [ + (datetime.datetime(2018, 11, 15, 1, 2, 3), 11), + (datetime.datetime(2018, 11, 25, 15, 20, 0), 1), + (datetime.datetime(2018, 11, 25, 15, 30, 0), 0), +] diff --git a/tests/unit/plugins/module_utils/acme/test_acme.py b/tests/unit/plugins/module_utils/acme/test_acme.py deleted file mode 100644 index 6fe90e6d..00000000 --- a/tests/unit/plugins/module_utils/acme/test_acme.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import absolute_import, division, print_function -__metaclass__ = type - -import base64 -import datetime -import os.path -import pytest - -from mock import MagicMock - - -from ansible_collections.community.crypto.plugins.module_utils.acme import ( - HAS_CURRENT_CRYPTOGRAPHY, - nopad_b64, - write_file, - read_file, - pem_to_der, - _parse_key_openssl, - # _sign_request_openssl, - _parse_key_cryptography, - # _sign_request_cryptography, - _normalize_ip, - openssl_get_csr_identifiers, - cryptography_get_csr_identifiers, - cryptography_get_cert_days, -) - - -def load_fixture(name): - with open(os.path.join(os.path.dirname(__file__), 'fixtures', name)) as f: - return f.read() - - -################################################ - -NOPAD_B64 = [ - ("", ""), - ("\n", "Cg"), - ("123", "MTIz"), - ("Lorem?ipsum", "TG9yZW0_aXBzdW0"), -] - - -@pytest.mark.parametrize("value, result", NOPAD_B64) -def test_nopad_b64(value, result): - assert nopad_b64(value.encode('utf-8')) == result - - -################################################ - -TEST_TEXT = r"""1234 -5678""" - - -def test_read_file(tmpdir): - fn = tmpdir / 'test.txt' - fn.write(TEST_TEXT) - assert read_file(str(fn), 't') == TEST_TEXT - assert read_file(str(fn), 'b') == TEST_TEXT.encode('utf-8') - - -def test_write_file(tmpdir): - fn = tmpdir / 'test.txt' - module = MagicMock() - write_file(module, str(fn), TEST_TEXT.encode('utf-8')) - assert fn.read() == TEST_TEXT - - -################################################ - -TEST_PEM_DERS = [ - ( - load_fixture('privatekey_1.pem'), - base64.b64decode('MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAo' - 'GCCqGSM49AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3' - 'lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw==') - ) -] - - -@pytest.mark.parametrize("pem, der", TEST_PEM_DERS) -def test_pem_to_der(pem, der, tmpdir): - fn = tmpdir / 'test.pem' - fn.write(pem) - assert pem_to_der(str(fn)) == der - - -################################################ - -TEST_KEYS = [ - ( - load_fixture('privatekey_1.pem'), - { - 'alg': 'ES256', - 'hash': 'sha256', - 'jwk': { - 'crv': 'P-256', - 'kty': 'EC', - 'x': 'AJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68E', - 'y': 'vEEs4V0egJkNyM2Q4pp001zu14VcpQ0_Ei8xOOPxKZs', - }, - 'point_size': 32, - 'type': 'ec', - }, - load_fixture('privatekey_1.txt'), - ) -] - - -@pytest.mark.parametrize("pem, result, openssl_output", TEST_KEYS) -def test_eckeyparse_openssl(pem, result, openssl_output, tmpdir): - fn = tmpdir / 'test.key' - fn.write(pem) - module = MagicMock() - module.run_command = MagicMock(return_value=(0, openssl_output, 0)) - error, key = _parse_key_openssl('openssl', module, key_file=str(fn)) - assert error is None - key.pop('key_file') - assert key == result - - -if HAS_CURRENT_CRYPTOGRAPHY: - @pytest.mark.parametrize("pem, result, dummy", TEST_KEYS) - def test_eckeyparse_cryptography(pem, result, dummy): - module = MagicMock() - error, key = _parse_key_cryptography(module, key_content=pem) - assert error is None - key.pop('key_obj') - assert key == result - - -################################################ - -TEST_IPS = [ - ("0:0:0:0:0:0:0:1", "::1"), - ("1::0:2", "1::2"), - ("0000:0001:0000:0000:0000:0000:0000:0001", "0:1::1"), - ("0000:0001:0000:0000:0001:0000:0000:0001", "0:1::1:0:0:1"), - ("0000:0001:0000:0001:0000:0001:0000:0001", "0:1:0:1:0:1:0:1"), - ("0.0.0.0", "0.0.0.0"), - ("000.001.000.000", "0.1.0.0"), - ("2001:d88:ac10:fe01:0:0:0:0", "2001:d88:ac10:fe01::"), - ("0000:0000:0000:0000:0000:0000:0000:0000", "::"), -] - - -@pytest.mark.parametrize("ip, result", TEST_IPS) -def test_normalize_ip(ip, result): - assert _normalize_ip(ip) == result - - -################################################ - -TEST_CSRS = [ - ( - load_fixture('csr_1.pem'), - set([ - ('dns', 'ansible.com'), - ('dns', 'example.com'), - ('dns', 'example.org') - ]), - load_fixture('csr_1.txt'), - ), - ( - load_fixture('csr_2.pem'), - set([ - ('dns', 'ansible.com'), - ('ip', '127.0.0.1'), - ('ip', '::1'), - ('ip', '2001:d88:ac10:fe01::'), - ('ip', '2001:1234:5678:abcd:9876:5432:10fe:dcba') - ]), - load_fixture('csr_2.txt'), - ), -] - - -@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS) -def test_csridentifiers_openssl(csr, result, openssl_output, tmpdir): - fn = tmpdir / 'test.csr' - fn.write(csr) - module = MagicMock() - module.run_command = MagicMock(return_value=(0, openssl_output, 0)) - identifiers = openssl_get_csr_identifiers('openssl', module, str(fn)) - assert identifiers == result - - -if HAS_CURRENT_CRYPTOGRAPHY: - @pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS) - def test_csridentifiers_cryptography(csr, result, openssl_output, tmpdir): - fn = tmpdir / 'test.csr' - fn.write(csr) - module = MagicMock() - identifiers = cryptography_get_csr_identifiers(module, str(fn)) - assert identifiers == result - - -################################################ - -TEST_CERT = load_fixture("cert_1.pem") - -TEST_CERT_DAYS = [ - (datetime.datetime(2018, 11, 15, 1, 2, 3), 11), - (datetime.datetime(2018, 11, 25, 15, 20, 0), 1), - (datetime.datetime(2018, 11, 25, 15, 30, 0), 0), -] - - -if HAS_CURRENT_CRYPTOGRAPHY: - @pytest.mark.parametrize("now, expected_days", TEST_CERT_DAYS) - def test_certdays_cryptography(now, expected_days, tmpdir): - fn = tmpdir / 'test-cert.pem' - fn.write(TEST_CERT) - module = MagicMock() - days = cryptography_get_cert_days(module, str(fn), now=now) - assert days == expected_days diff --git a/tests/unit/plugins/module_utils/acme/test_backend_cryptography.py b/tests/unit/plugins/module_utils/acme/test_backend_cryptography.py new file mode 100644 index 00000000..3061f7f4 --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_backend_cryptography.py @@ -0,0 +1,64 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import pytest + +from mock import MagicMock + + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import ( + HAS_CURRENT_CRYPTOGRAPHY, + CryptographyBackend, +) + +from .backend_data import ( + TEST_KEYS, + TEST_CSRS, + TEST_CERT, + TEST_CERT_DAYS, +) + + +if not HAS_CURRENT_CRYPTOGRAPHY: + pytest.skip('cryptography not found') + + +@pytest.mark.parametrize("pem, result, dummy", TEST_KEYS) +def test_eckeyparse_cryptography(pem, result, dummy, tmpdir): + fn = tmpdir / 'test.pem' + fn.write(pem) + module = MagicMock() + backend = CryptographyBackend(module) + error, key = backend.parse_key(key_file=str(fn)) + assert error is None + key.pop('key_obj') + assert key == result + error, key = backend.parse_key(key_content=pem) + assert error is None + key.pop('key_obj') + assert key == result + + +@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS) +def test_csridentifiers_cryptography(csr, result, openssl_output, tmpdir): + fn = tmpdir / 'test.csr' + fn.write(csr) + module = MagicMock() + backend = CryptographyBackend(module) + identifiers = backend.get_csr_identifiers(csr_filename=str(fn)) + assert identifiers == result + identifiers = backend.get_csr_identifiers(csr_content=csr) + assert identifiers == result + + +@pytest.mark.parametrize("now, expected_days", TEST_CERT_DAYS) +def test_certdays_cryptography(now, expected_days, tmpdir): + fn = tmpdir / 'test-cert.pem' + fn.write(TEST_CERT) + module = MagicMock() + backend = CryptographyBackend(module) + days = backend.get_cert_days(cert_filename=str(fn), now=now) + assert days == expected_days + days = backend.get_cert_days(cert_content=TEST_CERT, now=now) + assert days == expected_days diff --git a/tests/unit/plugins/module_utils/acme/test_backend_openssl_cli.py b/tests/unit/plugins/module_utils/acme/test_backend_openssl_cli.py new file mode 100644 index 00000000..26c31f8c --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_backend_openssl_cli.py @@ -0,0 +1,61 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import pytest + +from mock import MagicMock + + +from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import ( + OpenSSLCLIBackend, +) + +from .backend_data import ( + TEST_KEYS, + TEST_CSRS, +) + + +TEST_IPS = [ + ("0:0:0:0:0:0:0:1", "::1"), + ("1::0:2", "1::2"), + ("0000:0001:0000:0000:0000:0000:0000:0001", "0:1::1"), + ("0000:0001:0000:0000:0001:0000:0000:0001", "0:1::1:0:0:1"), + ("0000:0001:0000:0001:0000:0001:0000:0001", "0:1:0:1:0:1:0:1"), + ("0.0.0.0", "0.0.0.0"), + ("000.001.000.000", "0.1.0.0"), + ("2001:d88:ac10:fe01:0:0:0:0", "2001:d88:ac10:fe01::"), + ("0000:0000:0000:0000:0000:0000:0000:0000", "::"), +] + + +@pytest.mark.parametrize("pem, result, openssl_output", TEST_KEYS) +def test_eckeyparse_openssl(pem, result, openssl_output, tmpdir): + fn = tmpdir / 'test.key' + fn.write(pem) + module = MagicMock() + module.run_command = MagicMock(return_value=(0, openssl_output, 0)) + backend = OpenSSLCLIBackend(module, openssl_binary='openssl') + error, key = backend.parse_key(key_file=str(fn)) + assert error is None + key.pop('key_file') + assert key == result + + +@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS) +def test_csridentifiers_openssl(csr, result, openssl_output, tmpdir): + fn = tmpdir / 'test.csr' + fn.write(csr) + module = MagicMock() + module.run_command = MagicMock(return_value=(0, openssl_output, 0)) + backend = OpenSSLCLIBackend(module, openssl_binary='openssl') + identifiers = backend.get_csr_identifiers(str(fn)) + assert identifiers == result + + +@pytest.mark.parametrize("ip, result", TEST_IPS) +def test_normalize_ip(ip, result): + module = MagicMock() + backend = OpenSSLCLIBackend(module, openssl_binary='openssl') + assert backend._normalize_ip(ip) == result diff --git a/tests/unit/plugins/module_utils/acme/test_io.py b/tests/unit/plugins/module_utils/acme/test_io.py new file mode 100644 index 00000000..8c778d1d --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_io.py @@ -0,0 +1,29 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +from mock import MagicMock + + +from ansible_collections.community.crypto.plugins.module_utils.acme.io import ( + read_file, + write_file, +) + + +TEST_TEXT = r"""1234 +5678""" + + +def test_read_file(tmpdir): + fn = tmpdir / 'test.txt' + fn.write(TEST_TEXT) + assert read_file(str(fn), 't') == TEST_TEXT + assert read_file(str(fn), 'b') == TEST_TEXT.encode('utf-8') + + +def test_write_file(tmpdir): + fn = tmpdir / 'test.txt' + module = MagicMock() + write_file(module, str(fn), TEST_TEXT.encode('utf-8')) + assert fn.read() == TEST_TEXT diff --git a/tests/unit/plugins/module_utils/acme/test_utils.py b/tests/unit/plugins/module_utils/acme/test_utils.py new file mode 100644 index 00000000..a104eef5 --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_utils.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import pytest + + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + nopad_b64, + pem_to_der, +) + +from .backend_data import ( + TEST_PEM_DERS, +) + + +NOPAD_B64 = [ + ("", ""), + ("\n", "Cg"), + ("123", "MTIz"), + ("Lorem?ipsum", "TG9yZW0_aXBzdW0"), +] + + +@pytest.mark.parametrize("value, result", NOPAD_B64) +def test_nopad_b64(value, result): + assert nopad_b64(value.encode('utf-8')) == result + + +@pytest.mark.parametrize("pem, der", TEST_PEM_DERS) +def test_pem_to_der(pem, der, tmpdir): + fn = tmpdir / 'test.pem' + fn.write(pem) + assert pem_to_der(str(fn)) == der