diff --git a/antsibull-nox.toml b/antsibull-nox.toml index 4b8ab3803..c471a92d3 100644 --- a/antsibull-nox.toml +++ b/antsibull-nox.toml @@ -33,8 +33,10 @@ mypy_ansible_core_package = "ansible-core>=2.19.0" mypy_config = ".mypy.ini" mypy_extra_deps = [ "cryptography", + "pyasn1", "types-mock", "types-PyYAML", + "types-pyasn1", ] [sessions.docs_check] @@ -98,7 +100,7 @@ test_playbooks = ["tests/ee/all.yml"] config.images.base_image.name = "docker.io/redhat/ubi9:latest" config.dependencies.ansible_core.package_pip = "https://github.com/ansible/ansible/archive/devel.tar.gz" config.dependencies.ansible_runner.package_pip = "ansible-runner" -config.dependencies.python_interpreter.package_system = "python3.12 python3.12-pip python3.12-wheel python3.12-cryptography" +config.dependencies.python_interpreter.package_system = "python3.12 python3.12-pip python3.12-wheel python3.12-cryptography python3-pyasn1" config.dependencies.python_interpreter.python_path = "/usr/bin/python3.12" runtime_environment = {"ANSIBLE_PRIVATE_ROLE_VARS" = "true"} @@ -109,7 +111,7 @@ test_playbooks = ["tests/ee/all.yml"] config.images.base_image.name = "quay.io/rockylinux/rockylinux:9" config.dependencies.ansible_core.package_pip = "https://github.com/ansible/ansible/archive/stable-2.17.tar.gz" config.dependencies.ansible_runner.package_pip = "ansible-runner" -config.dependencies.python_interpreter.package_system = "python3.11 python3.11-pip python3.11-wheel python3.11-cryptography" +config.dependencies.python_interpreter.package_system = "python3.11 python3.11-pip python3.11-wheel python3.11-cryptography python3-pyasn1" config.dependencies.python_interpreter.python_path = "/usr/bin/python3.11" runtime_environment = {"ANSIBLE_PRIVATE_ROLE_VARS" = "true"} @@ -120,6 +122,6 @@ test_playbooks = ["tests/ee/all.yml"] config.images.base_image.name = "quay.io/centos/centos:stream9" config.dependencies.ansible_core.package_pip = "https://github.com/ansible/ansible/archive/stable-2.18.tar.gz" config.dependencies.ansible_runner.package_pip = "ansible-runner" -config.dependencies.python_interpreter.package_system = "python3.11 python3.11-pip python3.11-wheel python3.11-cryptography" +config.dependencies.python_interpreter.package_system = "python3.11 python3.11-pip python3.11-wheel python3.11-cryptography python3-pyasn1" config.dependencies.python_interpreter.python_path = "/usr/bin/python3.11" runtime_environment = {"ANSIBLE_PRIVATE_ROLE_VARS" = "true"} diff --git a/meta/ee-bindep.txt b/meta/ee-bindep.txt index ca4dc6972..7753ed8e5 100644 --- a/meta/ee-bindep.txt +++ b/meta/ee-bindep.txt @@ -10,4 +10,6 @@ openssl [platform:dpkg] openssl [platform:rpm] python3-cryptography [platform:dpkg] python3-cryptography [platform:rpm] +python3-pyasn1 [platform:dpkg] +python3-pyasn1 [platform:rpm] python3-openssl [platform:dpkg] diff --git a/noxfile.py b/noxfile.py index 6d8607cfd..2f4e96b3f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -27,6 +27,8 @@ def create_certificates(session: nox.Session) -> None: Regenerate some vendored certificates. """ session.install("cryptography<39.0.0") # we want support for SHA1 signatures + session.install("pyasn1>=0.4.8") + session.install("types-pyasn1>=0.4.8") session.run("python", "tests/create-certificates.py") session.warn( "Note that you need to modify some values in tests/integration/targets/x509_certificate_info/tasks/impl.yml" diff --git a/plugins/doc_fragments/_module_csr.py b/plugins/doc_fragments/_module_csr.py index a14ac6cc5..a94a02d01 100644 --- a/plugins/doc_fragments/_module_csr.py +++ b/plugins/doc_fragments/_module_csr.py @@ -21,6 +21,7 @@ class ModuleDocFragment: support: full requirements: - cryptography >= 3.3 + - pyasn1 >= 0.4.8 (only if O(custom_extensions) are specified) options: digest: description: @@ -325,6 +326,53 @@ class ModuleDocFragment: - privilege_withdrawn - aa_compromise version_added: 1.4.0 + custom_extensions: + description: + - Allows to specify one or multiple custom extensions. + - The extension value must not be empty, unless O(custom_extensions[].skip_if_empty) is specified. + - In such case the extension will be skipped instead of throwing an error. + - Custom extensions require the V(pyasn1) Python package to be installed in the environment where the code is run. + type: list + elements: dict + suboptions: + critical: + description: + - Set the critical flag. + type: bool + default: false + oid: + description: + - The OID of the custom extension + - 'Example: V(1.3.6.1.4.1.34380.1.1.25).' + type: str + skip_if_empty: + description: + - Allow empty value to be specified. In such case the extension will be skipped when processing. + type: bool + default: false + value_type: + description: + - The type of the value. Only valid if O(custom_extensions[].value) is specified. + - 'Supported data types are: V(str), V(bool), V(int), V(real).' + type: str + default: str + value: + description: + - The value of the custom extension. + - Mutually exclusive with O(custom_extensions[].value_raw) and O(custom_extensions[].value_b64). + type: str + value_raw: + description: + - The raw value of the custom extension. Will be copied to the extension as-is. + - The value is always assumed to be an UTF8String + - Mutually exclusive with O(custom_extensions[].value) and O(custom_extensions[].value_b64). + type: str + value_b64: + description: + - The raw value of the custom extension, base64 encoded. Will be decoded, then copied to the extension as-is. + - Mutually exclusive with O(custom_extensions[].value) and O(custom_extensions[].value_raw). + type: str + version_added: 3.1.0 notes: - If the certificate signing request already exists it will be checked whether subjectAltName, keyUsage, extendedKeyUsage and basicConstraints only contain the requested values, whether OCSP Must Staple is as requested, and if the request was diff --git a/plugins/module_utils/_crypto/module_backends/csr.py b/plugins/module_utils/_crypto/module_backends/csr.py index e5b1963d5..855c4c00c 100644 --- a/plugins/module_utils/_crypto/module_backends/csr.py +++ b/plugins/module_utils/_crypto/module_backends/csr.py @@ -8,6 +8,7 @@ from __future__ import annotations +import base64 import binascii import typing as t @@ -70,6 +71,12 @@ except ImportError: pass +try: + from pyasn1.codec.der import encoder + from pyasn1.type import char, univ +except ImportError: + pass + class CertificateSigningRequestError(OpenSSLObjectError): pass @@ -132,6 +139,78 @@ def parse_crl_distribution_points( return result +def parse_custom_extensions( + *, module: AnsibleModule, custom_extensions: list[dict[str, t.Any]] +) -> list[tuple[cryptography.x509.UnrecognizedExtension, bool]]: + result = [] + for index, custom_extension in enumerate(custom_extensions): + try: + if not custom_extension.get("oid"): + raise OpenSSLObjectError("oid must not be empty") + oid = custom_extension["oid"] + + critical = custom_extension.get("critical", False) + if not isinstance(critical, bool): + raise OpenSSLObjectError(f"critical must be boolean (oid {oid})") + + value = custom_extension.get("value", "") + value_type = custom_extension.get("value_type", "str") + value_raw = custom_extension.get("value_raw", "") + value_b64 = custom_extension.get("value_b64", "") + + if not value and not value_raw and not value_b64: + if custom_extension.get("skip_if_empty"): + continue + raise OpenSSLObjectError( + f"neither of value, value_raw, or value_b64 was specified (oid {oid})" + ) + + if sum(bool(x) for x in [value, value_raw, value_b64]) != 1: + raise OpenSSLObjectError( + f"exactly one of value, value_raw, or value_b64 can be set (oid {oid})" + ) + + if value_raw or value_b64: + extension_value = ( + value_raw.encode("UTF-8") + if value_raw + else base64.b64decode(value_b64) + ) + else: + if value_type == "str": + extension_value = encoder.encode(char.UTF8String(value)) + elif value_type == "bool": + value = value.strip().lower() + if value not in ["true", "false"]: + raise OpenSSLObjectError( + f"Unexpected bool value: {value} (oid {oid})" + ) + extension_value = encoder.encode(univ.Boolean(value == "true")) + elif value_type == "int": + extension_value = encoder.encode(univ.Integer(int(value.strip()))) + elif value_type == "real": + extension_value = encoder.encode(univ.Real(float(value.strip()))) + else: + raise OpenSSLObjectError( + f"Data type of value unknown; supported types: str, bool, int, float (oid {oid}" + ) + + result.append( + ( + cryptography.x509.UnrecognizedExtension( + oid=cryptography.x509.ObjectIdentifier(oid), + value=extension_value, + ), + critical, + ) + ) + except (OpenSSLObjectError, ValueError) as e: + raise OpenSSLObjectError( + f"Error while parsing custom extension #{index}: {e}" + ) from e + return result + + class CertificateSigningRequestBackend: def __init__(self, *, module: AnsibleModule) -> None: self.module = module @@ -185,6 +264,10 @@ def __init__(self, *, module: AnsibleModule) -> None: self.crl_distribution_points: ( list[cryptography.x509.DistributionPoint] | None ) = None + self.custom_extensions: ( + list[tuple[cryptography.x509.UnrecognizedExtension, bool]] | None + ) = None + self.csr: cryptography.x509.CertificateSigningRequest | None = None self.privatekey: CertificateIssuerPrivateKeyTypes | None = None @@ -268,6 +351,14 @@ def __init__(self, *, module: AnsibleModule) -> None: module=module, crl_distribution_points=crl_distribution_points ) + custom_extensions: list[dict[str, t.Any]] | None = module.params[ + "custom_extensions" + ] + if custom_extensions: + self.custom_extensions = parse_custom_extensions( + module=module, custom_extensions=custom_extensions + ) + def _get_info(self, *, data: bytes | None) -> dict[str, t.Any]: if data is None: return {} @@ -413,6 +504,10 @@ def generate_csr(self) -> None: critical=False, ) + if self.custom_extensions: + for custom_extension, critical in self.custom_extensions: + csr = csr.add_extension(custom_extension, critical=critical) + # csr.sign() does not accept some digests we theoretically could have in digest. # For that reason we use type t.Any here. csr.sign() will complain if # the digest is not acceptable. @@ -906,6 +1001,21 @@ def get_csr_argument_spec() -> ArgumentSpec: "mutually_exclusive": [("full_name", "relative_name")], "required_one_of": [("full_name", "relative_name", "crl_issuer")], }, + "custom_extensions": { + "type": "list", + "elements": "dict", + "options": { + "critical": {"type": "bool", "default": False}, + "oid": {"type": "str"}, + "skip_if_empty": {"type": "bool", "default": False}, + "value_type": {"type": "str", "default": "str"}, + "value": {"type": "str"}, + "value_raw": {"type": "str"}, + "value_b64": {"type": "str"}, + }, + "mutually_exclusive": [("value", "value_raw", "value_b64")], + "required_one_of": [("value", "value_raw", "value_b64")], + }, "select_crypto_backend": { "type": "str", "default": "auto", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..5f39086c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# 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 + +pyasn1>=0.4.8 +types-pyasn1>=0.4.8 diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt new file mode 120000 index 000000000..fd1efae71 --- /dev/null +++ b/tests/integration/requirements.txt @@ -0,0 +1 @@ +../../requirements.txt \ No newline at end of file diff --git a/tests/integration/targets/openssl_csr/tasks/impl.yml b/tests/integration/targets/openssl_csr/tasks/impl.yml index 96c32e028..34cacb633 100644 --- a/tests/integration/targets/openssl_csr/tasks/impl.yml +++ b/tests/integration/targets/openssl_csr/tasks/impl.yml @@ -599,6 +599,36 @@ authority_cert_issuer: '{{ value_for_authority_cert_issuer }}' authority_cert_serial_number: 12345 select_crypto_backend: '{{ select_crypto_backend }}' + custom_extensions: + - oid: 1.3.6.1.4.1.34380.1.2.1 + value: "DE" # stored as an UTF8String + critical: false + - oid: 1.3.6.1.4.1.34380.1.2.2 + value: "" + critical: false + skip_if_empty: true # will not be stored to the CSR + - oid: 1.3.6.1.4.1.34380.1.2.3 + value: 17 + value_type: int + critical: false + - oid: 1.3.6.1.4.1.34380.1.2.4 + value: 3.14 + value_type: real + critical: false + - oid: 1.3.6.1.4.1.34380.1.2.5 + value: true + value_type: bool + critical: false + - oid: 1.3.6.1.4.1.34380.1.2.6 + value: false + value_type: bool + critical: false + - oid: 1.3.6.1.4.1.34380.1.2.7 + value_raw: "DE" # stored as an OctetString (raw) + critical: false + - oid: 1.3.6.1.4.1.34380.1.2.8 + value_b64: "REU=" # DE, base64 encoded, stored as an OctetString (raw) + critical: true vars: value_for_extended_key_usage: - serverAuth # the same as "TLS Web Server Authentication" diff --git a/tests/integration/targets/openssl_csr/tests/validate.yml b/tests/integration/targets/openssl_csr/tests/validate.yml index 9528738d3..4262651d1 100644 --- a/tests/integration/targets/openssl_csr/tests/validate.yml +++ b/tests/integration/targets/openssl_csr/tests/validate.yml @@ -304,6 +304,29 @@ "IP:1.2.3.0/24", "IP:::1:0:0/112", ] + - '"1.3.6.1.4.1.34380.1.2.1" in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.2" not in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.3" in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.4" in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.5" in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.6" in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.7" in everything_info.extensions_by_oid' + - '"1.3.6.1.4.1.34380.1.2.8" in everything_info.extensions_by_oid' + # values provided by csr_info are always base64 encoded + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.1"].critical == false + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.1"].value == "DAJERQ==" + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.3"].critical == false + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.3"].value == "AgER" + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.4"].critical == false + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.4"].value == "CQcDMzE0RS0y" + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.5"].critical == false + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.5"].value == "AQH/" + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.6"].critical == false + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.6"].value == "AQEA" + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.7"].critical == false + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.7"].value == "REU=" + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.8"].critical == true + - everything_info.extensions_by_oid["1.3.6.1.4.1.34380.1.2.8"].value == "REU=" - name: "({{ select_crypto_backend }}) Verify Ed25519 and Ed448 tests" ansible.builtin.assert: diff --git a/tests/integration/targets/setup_openssl/tasks/main.yml b/tests/integration/targets/setup_openssl/tasks/main.yml index 406dc7908..ed8365afa 100644 --- a/tests/integration/targets/setup_openssl/tasks/main.yml +++ b/tests/integration/targets/setup_openssl/tasks/main.yml @@ -102,12 +102,24 @@ name: '{{ cryptography_package_name_python3 }}' when: ansible_python_version is version('3.0', '>=') + - name: Install pyasn1 (Python 3 from system packages) + become: true + ansible.builtin.package: + name: '{{ pyasn1_package_name_python3 }}' + when: ansible_python_version is version('3.0', '>=') + - name: Install cryptography (Python 2 from system packages) become: true ansible.builtin.package: name: '{{ cryptography_package_name }}' when: ansible_python_version is version('3.0', '<') + - name: Install pyasn1 (Python 2 from system packages) + become: true + ansible.builtin.package: + name: '{{ pyasn1_package_name }}' + when: ansible_python_version is version('3.0', '<') + - name: Install from PyPi when: ansible_os_family == "Darwin" or not target_system_python block: diff --git a/tests/integration/targets/setup_openssl/vars/Alpine.yml b/tests/integration/targets/setup_openssl/vars/Alpine.yml index bb13d46f1..690a1fcc2 100644 --- a/tests/integration/targets/setup_openssl/vars/Alpine.yml +++ b/tests/integration/targets/setup_openssl/vars/Alpine.yml @@ -6,3 +6,5 @@ openssl_package_name: openssl cryptography_package_name: py-cryptography cryptography_package_name_python3: py3-cryptography +pyasn1_package_name: py-asn1 +pyasn1_package_name_python3: py3-asn1 diff --git a/tests/integration/targets/setup_openssl/vars/Archlinux.yml b/tests/integration/targets/setup_openssl/vars/Archlinux.yml index 81d64a9aa..3c79ba56f 100644 --- a/tests/integration/targets/setup_openssl/vars/Archlinux.yml +++ b/tests/integration/targets/setup_openssl/vars/Archlinux.yml @@ -6,3 +6,5 @@ openssl_package_name: openssl cryptography_package_name: python-cryptography cryptography_package_name_python3: python-cryptography +pyasn1_package_name: python-pyasn1 +pyasn1_package_name_python3: python-pyasn1 diff --git a/tests/integration/targets/setup_openssl/vars/Debian.yml b/tests/integration/targets/setup_openssl/vars/Debian.yml index 6609983a2..0053fe0f8 100644 --- a/tests/integration/targets/setup_openssl/vars/Debian.yml +++ b/tests/integration/targets/setup_openssl/vars/Debian.yml @@ -6,3 +6,5 @@ openssl_package_name: openssl cryptography_package_name: python-cryptography cryptography_package_name_python3: python3-cryptography +pyasn1_package_name: python-pyasn1 +pyasn1_package_name_python3: python3-pyasn1 diff --git a/tests/integration/targets/setup_openssl/vars/FreeBSD.yml b/tests/integration/targets/setup_openssl/vars/FreeBSD.yml index 1b6bdd8bb..badf26665 100644 --- a/tests/integration/targets/setup_openssl/vars/FreeBSD.yml +++ b/tests/integration/targets/setup_openssl/vars/FreeBSD.yml @@ -6,3 +6,5 @@ openssl_package_name: openssl cryptography_package_name: py27-cryptography cryptography_package_name_python3: "py{{ ansible_python.version.major }}{{ ansible_python.version.minor }}-cryptography" +pyasn1_package_name: py27-pyasn1 +pyasn1_package_name_python3: "py{{ ansible_python.version.major }}{{ ansible_python.version.minor }}-pyasn1" diff --git a/tests/integration/targets/setup_openssl/vars/RedHat.yml b/tests/integration/targets/setup_openssl/vars/RedHat.yml index 6609983a2..0053fe0f8 100644 --- a/tests/integration/targets/setup_openssl/vars/RedHat.yml +++ b/tests/integration/targets/setup_openssl/vars/RedHat.yml @@ -6,3 +6,5 @@ openssl_package_name: openssl cryptography_package_name: python-cryptography cryptography_package_name_python3: python3-cryptography +pyasn1_package_name: python-pyasn1 +pyasn1_package_name_python3: python3-pyasn1 diff --git a/tests/integration/targets/setup_openssl/vars/Suse.yml b/tests/integration/targets/setup_openssl/vars/Suse.yml index 6609983a2..ff9069804 100644 --- a/tests/integration/targets/setup_openssl/vars/Suse.yml +++ b/tests/integration/targets/setup_openssl/vars/Suse.yml @@ -6,3 +6,5 @@ openssl_package_name: openssl cryptography_package_name: python-cryptography cryptography_package_name_python3: python3-cryptography +pyasn1_package_name: python-pyasn1 +pyasn1_package_name_python3: python-pyasn1