#!/usr/bin/python # Copyright (c) 2018 Felix Fontein # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import annotations DOCUMENTATION = r""" module: acme_challenge_cert_helper author: "Felix Fontein (@felixfontein)" short_description: Prepare certificates required for ACME challenges such as C(tls-alpn-01) description: - Prepares certificates for ACME challenges such as C(tls-alpn-01). - The raw data is provided by the M(community.crypto.acme_certificate) module, and needs to be converted to a certificate to be used for challenge validation. This module provides a simple way to generate the required certificates. seealso: - name: Automatic Certificate Management Environment (ACME) description: The specification of the ACME protocol (RFC 8555). link: https://tools.ietf.org/html/rfc8555 - name: ACME TLS ALPN Challenge Extension description: The specification of the C(tls-alpn-01) challenge (RFC 8737). link: https://www.rfc-editor.org/rfc/rfc8737.html extends_documentation_fragment: - community.crypto._attributes - community.crypto._cryptography_dep.minimum attributes: check_mode: support: none details: - This action does not modify state. diff_mode: support: N/A details: - This action does not modify state. idempotent: support: none details: - The certificates returned are never the same, since the Not Before and Not After timestamps depend on the invocation's timestamp. options: challenge: description: - The challenge type. type: str required: true choices: - tls-alpn-01 challenge_data: description: - The RV(community.crypto.acme_certificate#module:challenge_data) entry provided by M(community.crypto.acme_certificate) for the challenge. type: dict required: true private_key_src: description: - Path to a file containing the private key file to use for this challenge certificate. - Mutually exclusive with O(private_key_content). type: path private_key_content: description: - Content of the private key to use for this challenge certificate. - Mutually exclusive with O(private_key_src). type: str private_key_passphrase: description: - Passphrase to use to decode the private key. type: str version_added: 1.6.0 """ EXAMPLES = r""" --- - name: Create challenges for a given CRT for sample.com community.crypto.acme_certificate: account_key_src: /etc/pki/cert/private/account.key challenge: tls-alpn-01 csr: /etc/pki/cert/csr/sample.com.csr dest: /etc/httpd/ssl/sample.com.crt modify_account: false register: sample_com_challenge - name: Create certificates for challenges community.crypto.acme_challenge_cert_helper: challenge: tls-alpn-01 challenge_data: "{{ item.value['tls-alpn-01'] }}" private_key_src: /etc/pki/cert/key/sample.com.key loop: "{{ sample_com_challenge.challenge_data | dictsort }}" register: sample_com_challenge_certs - name: Install challenge certificates # We need to set up HTTPS such that for the domain, # regular_certificate is delivered for regular connections, # except if ALPN selects the "acme-tls/1"; then, the # challenge_certificate must be delivered. # This can for example be achieved with very new versions # of NGINX; search for ssl_preread and # ssl_preread_alpn_protocols for information on how to # route by ALPN protocol. ...: domain: "{{ item.domain }}" challenge_certificate: "{{ item.challenge_certificate }}" regular_certificate: "{{ item.regular_certificate }}" private_key: /etc/pki/cert/key/sample.com.key loop: "{{ sample_com_challenge_certs.results }}" - name: Create certificate for a given CSR for sample.com community.crypto.acme_certificate: account_key_src: /etc/pki/cert/private/account.key challenge: tls-alpn-01 csr: /etc/pki/cert/csr/sample.com.csr dest: /etc/httpd/ssl/sample.com.crt data: "{{ sample_com_challenge }}" modify_account: false """ RETURN = r""" domain: description: - The domain the challenge is for. The certificate should be provided if this is specified in the request's the C(Host) header. returned: always type: str identifier_type: description: - The identifier type for the actual resource identifier. returned: always type: str choices: - dns - ip identifier: description: - The identifier for the actual resource. Will be a domain name if RV(identifier_type=dns), or an IP address if RV(identifier_type=ip). returned: always type: str challenge_certificate: description: - The challenge certificate in PEM format. returned: always type: str regular_certificate: description: - A self-signed certificate for the challenge domain. - If no existing certificate exists, can be used to set-up https in the first place if that is needed for providing the challenge. returned: always type: str """ import base64 import datetime import ipaddress import typing as t from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_bytes, to_text 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 from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, set_not_valid_after, set_not_valid_before, ) from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import ( COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION, assert_required_cryptography_version, ) from ansible_collections.community.crypto.plugins.module_utils._time import ( get_now_datetime, ) try: import cryptography import cryptography.hazmat.backends import cryptography.hazmat.primitives.asymmetric.dsa import cryptography.hazmat.primitives.asymmetric.ec import cryptography.hazmat.primitives.asymmetric.ed448 import cryptography.hazmat.primitives.asymmetric.ed25519 import cryptography.hazmat.primitives.asymmetric.padding import cryptography.hazmat.primitives.asymmetric.rsa import cryptography.hazmat.primitives.asymmetric.utils import cryptography.hazmat.primitives.hashes import cryptography.hazmat.primitives.serialization import cryptography.x509 import cryptography.x509.oid except ImportError: pass # Convert byte string to ASN1 encoded octet string def encode_octet_string(octet_string: bytes) -> bytes: if len(octet_string) >= 128: raise ModuleFailException( "Cannot handle octet strings with more than 128 bytes" ) return bytes([0x4, len(octet_string)]) + octet_string def main() -> t.NoReturn: module = AnsibleModule( argument_spec={ "challenge": {"type": "str", "required": True, "choices": ["tls-alpn-01"]}, "challenge_data": {"type": "dict", "required": True}, "private_key_src": {"type": "path"}, "private_key_content": {"type": "str", "no_log": True}, "private_key_passphrase": {"type": "str", "no_log": True}, }, required_one_of=(["private_key_src", "private_key_content"],), mutually_exclusive=(["private_key_src", "private_key_content"],), ) assert_required_cryptography_version( module, minimum_cryptography_version=COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION ) try: # Get parameters challenge: t.Literal["tls-alpn-01"] = module.params["challenge"] challenge_data: dict[str, t.Any] = module.params["challenge_data"] # Get hold of private key private_key_content_str: str | None = module.params["private_key_content"] private_key_passphrase: str | None = module.params["private_key_passphrase"] if private_key_content_str is None: private_key_content = read_file(module.params["private_key_src"]) else: private_key_content = to_bytes(private_key_content_str) try: private_key = ( cryptography.hazmat.primitives.serialization.load_pem_private_key( private_key_content, password=( to_bytes(private_key_passphrase) if private_key_passphrase is not None else None ), ) ) except Exception as e: raise ModuleFailException(f"Error while loading private key: {e}") from e if not isinstance( private_key, ( cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey, ), ): raise ModuleFailException( f"Cannot use private key type {type(private_key)}" ) # Some common attributes domain = to_text(challenge_data["resource"]) identifier_type, identifier = to_text( challenge_data.get("resource_original", "dns:" + challenge_data["resource"]) ).split(":", 1) subject = issuer = cryptography.x509.Name([]) now = get_now_datetime(with_timezone=CRYPTOGRAPHY_TIMEZONE) not_valid_before = now not_valid_after = now + datetime.timedelta(days=10) san: cryptography.x509.GeneralName if identifier_type == "dns": san = cryptography.x509.DNSName(identifier) elif identifier_type == "ip": san = cryptography.x509.IPAddress(ipaddress.ip_address(identifier)) else: raise ModuleFailException( f'Unsupported identifier type "{identifier_type}"' ) # Generate regular self-signed certificate cert_builder = ( cryptography.x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(private_key.public_key()) .serial_number(cryptography.x509.random_serial_number()) .add_extension( cryptography.x509.SubjectAlternativeName([san]), critical=False, ) ) cert_builder = set_not_valid_before(cert_builder, not_valid_before) cert_builder = set_not_valid_after(cert_builder, not_valid_after) regular_certificate = cert_builder.sign( private_key, cryptography.hazmat.primitives.hashes.SHA256(), ) # Process challenge if challenge == "tls-alpn-01": value = base64.b64decode(challenge_data["resource_value"]) cert_builder = ( cryptography.x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(private_key.public_key()) .serial_number(cryptography.x509.random_serial_number()) .add_extension( cryptography.x509.SubjectAlternativeName([san]), critical=False, ) .add_extension( cryptography.x509.UnrecognizedExtension( cryptography.x509.ObjectIdentifier("1.3.6.1.5.5.7.1.31"), encode_octet_string(value), ), critical=True, ) ) cert_builder = set_not_valid_before(cert_builder, not_valid_before) cert_builder = set_not_valid_after(cert_builder, not_valid_after) challenge_certificate = cert_builder.sign( private_key, cryptography.hazmat.primitives.hashes.SHA256(), ) else: raise AssertionError("Can never be reached") # pragma: no cover module.exit_json( changed=True, domain=domain, identifier_type=identifier_type, identifier=identifier, challenge_certificate=challenge_certificate.public_bytes( cryptography.hazmat.primitives.serialization.Encoding.PEM ), regular_certificate=regular_certificate.public_bytes( cryptography.hazmat.primitives.serialization.Encoding.PEM ), ) except ModuleFailException as e: e.do_fail(module=module) if __name__ == "__main__": main()