diff --git a/changelogs/fragments/115-acme_certificate-csr_content.yml b/changelogs/fragments/115-acme_certificate-csr_content.yml new file mode 100644 index 00000000..c58a67de --- /dev/null +++ b/changelogs/fragments/115-acme_certificate-csr_content.yml @@ -0,0 +1,2 @@ +minor_changes: +- acme_certificate - allow to pass CSR file as content with new option ``csr_content`` (https://github.com/ansible-collections/community.crypto/pull/115). diff --git a/plugins/module_utils/acme.py b/plugins/module_utils/acme.py index 9ac24076..4b782e2a 100644 --- a/plugins/module_utils/acme.py +++ b/plugins/module_utils/acme.py @@ -140,27 +140,31 @@ def write_file(module, dest, content): return changed -def pem_to_der(pem_filename): +def pem_to_der(pem_filename, pem_content=None): ''' - Load PEM file, and convert to DER. + 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 = [] - try: - with open(pem_filename, "rt") as f: - header_line_count = 0 - for line in f: - if line.startswith('-----'): - header_line_count += 1 - if header_line_count == 2: - # If certificate file contains other certs appended - # (like intermediate certificates), ignore these. - break - continue - certificate_lines.append(line.strip()) - except Exception as err: - raise ModuleFailException("cannot load PEM file {0}: {1}".format(pem_filename, to_native(err)), exception=traceback.format_exc()) + 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)) @@ -989,14 +993,20 @@ def _normalize_ip(ip): return ip -def openssl_get_csr_identifiers(openssl_binary, module, csr_filename): +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'. ''' - openssl_csr_cmd = [openssl_binary, "req", "-in", csr_filename, "-noout", "-text"] - dummy, out, dummy = module.run_command(openssl_csr_cmd, check_rc=True) + 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')) @@ -1018,14 +1028,18 @@ def openssl_get_csr_identifiers(openssl_binary, module, csr_filename): return identifiers -def cryptography_get_csr_identifiers(module, csr_filename): +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([]) - csr = cryptography.x509.load_pem_x509_csr(read_file(csr_filename), _cryptography_backend) + 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)) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index 4cb958cb..cf16051a 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -125,9 +125,23 @@ options: account key. This is a bad idea from a security point of view, and the CA should not accept the CSR. The ACME server should return an error in this case." + - Precisely one of I(csr) or I(csr_content) must be specified. type: path - required: true aliases: ['src'] + csr_content: + description: + - "Content of the CSR for the new certificate." + - "Can be created with C(openssl req ...)." + - "The CSR may contain multiple Subject Alternate Names, but each one + will lead to an individual challenge that must be fulfilled for the + CSR to be signed." + - "I(Note): the private key used to create the CSR I(must not) be the + account key. This is a bad idea from a security point of view, and + the CA should not accept the CSR. The ACME server should return an + error in this case." + - Precisely one of I(csr) or I(csr_content) must be specified. + type: str + version_added: 1.2.0 data: description: - "The data to validate ongoing challenges. This must be specified for @@ -279,7 +293,7 @@ EXAMPLES = r''' - name: Create a challenge for sample.com using a account key file. community.crypto.acme_certificate: account_key_src: /etc/pki/cert/private/account.key - csr: /etc/pki/cert/csr/sample.com.csr + csr_content: "{{ lookup('file', '/etc/pki/cert/csr/sample.com.csr') }}" dest: /etc/httpd/ssl/sample.com.crt fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt register: sample_com_challenge @@ -576,6 +590,7 @@ class ACMEClient(object): self.version = module.params['acme_version'] self.challenge = module.params['challenge'] self.csr = module.params['csr'] + self.csr_content = module.params['csr_content'] self.dest = module.params.get('dest') self.fullchain_dest = module.params.get('fullchain_dest') self.chain_dest = module.params.get('chain_dest') @@ -613,7 +628,7 @@ class ACMEClient(object): # signed ACME request. pass - if not os.path.exists(self.csr): + 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) @@ -626,9 +641,9 @@ class ACMEClient(object): Parse the CSR and return the list of requested identifiers ''' if HAS_CURRENT_CRYPTOGRAPHY: - return cryptography_get_csr_identifiers(self.module, self.csr) + 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) + 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): ''' @@ -767,7 +782,7 @@ class ACMEClient(object): Return the certificate object as dict https://tools.ietf.org/html/rfc8555#section-7.4 ''' - csr = pem_to_der(self.csr) + csr = pem_to_der(self.csr, self.csr_content) new_cert = { "csr": nopad_b64(csr), } @@ -844,7 +859,7 @@ class ACMEClient(object): 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) + csr = pem_to_der(self.csr, self.csr_content) new_cert = { "resource": "new-cert", "csr": nopad_b64(csr), @@ -1177,7 +1192,8 @@ def main(): agreement=dict(type='str'), terms_agreed=dict(type='bool', default=False), challenge=dict(type='str', default='http-01', choices=['http-01', 'dns-01', 'tls-alpn-01']), - csr=dict(type='path', required=True, aliases=['src']), + csr=dict(type='path', aliases=['src']), + csr_content=dict(type='str'), data=dict(type='dict'), dest=dict(type='path', aliases=['cert']), fullchain_dest=dict(type='path', aliases=['fullchain']), @@ -1199,9 +1215,11 @@ def main(): required_one_of=( ['account_key_src', 'account_key_content'], ['dest', 'fullchain_dest'], + ['csr', 'csr_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content'], + ['csr', 'csr_content'], ), supports_check_mode=True, ) diff --git a/tests/integration/targets/acme_certificate/tasks/impl.yml b/tests/integration/targets/acme_certificate/tasks/impl.yml index 6757ec2e..9b0cacb2 100644 --- a/tests/integration/targets/acme_certificate/tasks/impl.yml +++ b/tests/integration/targets/acme_certificate/tasks/impl.yml @@ -62,6 +62,7 @@ select_chain: - test_certificates: last issuer: "{{ acme_roots[1].subject }}" + use_csr_content: true - name: Store obtain results for cert 1 set_fact: cert_1_obtain_results: "{{ certificate_obtain_result }}" @@ -93,6 +94,7 @@ subject: "{{ acme_intermediates[0].subject }}" - test_certificates: all issuer: "{{ acme_roots[2].subject }}" + use_csr_content: false - name: Store obtain results for cert 2 set_fact: cert_2_obtain_results: "{{ certificate_obtain_result }}" @@ -118,6 +120,7 @@ select_chain: - test_certificates: last subject: "{{ acme_roots[1].subject }}" + use_csr_content: true - name: Store obtain results for cert 3 set_fact: cert_3_obtain_results: "{{ certificate_obtain_result }}" @@ -145,6 +148,7 @@ issuer: "{{ acme_roots[2].subject }}" - test_certificates: last issuer: "{{ acme_roots[1].subject }}" + use_csr_content: false - name: Store obtain results for cert 4 set_fact: cert_4_obtain_results: "{{ certificate_obtain_result }}" @@ -165,6 +169,7 @@ remaining_days: 10 terms_agreed: no account_email: "" + use_csr_content: true - name: Store obtain results for cert 5a set_fact: cert_5a_obtain_results: "{{ certificate_obtain_result }}" @@ -185,6 +190,7 @@ remaining_days: 10 terms_agreed: no account_email: "" + use_csr_content: false - name: Store obtain results for cert 5b set_fact: cert_5_recreate_1: "{{ challenge_data is changed }}" @@ -204,6 +210,7 @@ remaining_days: 1000 terms_agreed: no account_email: "" + use_csr_content: true - name: Store obtain results for cert 5c set_fact: cert_5_recreate_2: "{{ challenge_data is changed }}" @@ -224,6 +231,7 @@ remaining_days: 10 terms_agreed: no account_email: "" + use_csr_content: false - name: Store obtain results for cert 5d set_fact: cert_5_recreate_3: "{{ challenge_data is changed }}" @@ -255,6 +263,7 @@ subject_key_identifier: "{{ acme_intermediates[0].subject_key_identifier }}" - test_certificates: last issuer: "{{ acme_roots[1].subject }}" + use_csr_content: true - name: Store obtain results for cert 6 set_fact: cert_6_obtain_results: "{{ certificate_obtain_result }}" @@ -282,6 +291,7 @@ select_chain: - test_certificates: last authority_key_identifier: "{{ acme_roots[2].subject_key_identifier }}" + use_csr_content: false - name: Store obtain results for cert 7 set_fact: cert_7_obtain_results: "{{ certificate_obtain_result }}" @@ -307,6 +317,7 @@ remaining_days: 10 terms_agreed: yes account_email: "example@example.org" + use_csr_content: true - name: Store obtain results for cert 8 set_fact: cert_8_obtain_results: "{{ certificate_obtain_result }}" diff --git a/tests/integration/targets/setup_acme/tasks/obtain-cert.yml b/tests/integration/targets/setup_acme/tasks/obtain-cert.yml index 98f5f804..45c15350 100644 --- a/tests/integration/targets/setup_acme/tasks/obtain-cert.yml +++ b/tests/integration/targets/setup_acme/tasks/obtain-cert.yml @@ -19,6 +19,8 @@ privatekey_path: "{{ output_dir }}/{{ certificate_name }}.key" subject_alt_name: "{{ subject_alt_name }}" subject_alt_name_critical: "{{ subject_alt_name_critical }}" + return_content: true + register: csr_result ## ACME STEP 1 ################################################################################ - name: ({{ certgen_title }}) Obtain cert, step 1 acme_certificate: @@ -29,7 +31,8 @@ account_key: "{{ (output_dir ~ '/' ~ account_key ~ '.pem') if account_key_content is not defined else omit }}" account_key_content: "{{ account_key_content | default(omit) }}" modify_account: "{{ modify_account }}" - csr: "{{ output_dir }}/{{ certificate_name }}.csr" + csr: "{{ omit if use_csr_content | default(false) else output_dir ~ '/' ~ certificate_name ~ '.csr' }}" + csr_content: "{{ csr_result.csr if use_csr_content | default(false) else omit }}" dest: "{{ output_dir }}/{{ certificate_name }}.pem" fullchain_dest: "{{ output_dir }}/{{ certificate_name }}-fullchain.pem" chain_dest: "{{ output_dir }}/{{ certificate_name }}-chain.pem" @@ -100,7 +103,8 @@ account_key_content: "{{ account_key_content | default(omit) }}" account_uri: "{{ challenge_data.account_uri }}" modify_account: "{{ modify_account }}" - csr: "{{ output_dir }}/{{ certificate_name }}.csr" + csr: "{{ omit if use_csr_content | default(false) else output_dir ~ '/' ~ certificate_name ~ '.csr' }}" + csr_content: "{{ csr_result.csr if use_csr_content | default(false) else omit }}" dest: "{{ output_dir }}/{{ certificate_name }}.pem" fullchain_dest: "{{ output_dir }}/{{ certificate_name }}-fullchain.pem" chain_dest: "{{ output_dir }}/{{ certificate_name }}-chain.pem"