From 960d99a7852877bebd06031b219a36ecf9674cbf Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Wed, 22 Aug 2018 23:12:43 +0200 Subject: [PATCH] ACME: new helper module for ACME challenges which need TLS certs (#43756) * Added helper module for generating ACME challenge certificates. * Soft-fail on missing cryptography. Also check version. * Adding integration test. * Move acme_challenge_cert_helper from web_infrastructure to crypto/acme. * Adjusting to draft-05. * The cryptography branch has already been merged. --- .../crypto/acme/acme_challenge_cert_helper.py | 264 ++++++++++++++++++ .../acme_challenge_cert_helper/aliases | 2 + .../acme_challenge_cert_helper/meta/main.yml | 2 + .../acme_challenge_cert_helper/tasks/main.yml | 25 ++ .../tasks/obtain-cert.yml | 1 + .../targets/setup_acme/tasks/obtain-cert.yml | 22 +- 6 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 lib/ansible/modules/crypto/acme/acme_challenge_cert_helper.py create mode 100644 test/integration/targets/acme_challenge_cert_helper/aliases create mode 100644 test/integration/targets/acme_challenge_cert_helper/meta/main.yml create mode 100644 test/integration/targets/acme_challenge_cert_helper/tasks/main.yml create mode 120000 test/integration/targets/acme_challenge_cert_helper/tasks/obtain-cert.yml diff --git a/lib/ansible/modules/crypto/acme/acme_challenge_cert_helper.py b/lib/ansible/modules/crypto/acme/acme_challenge_cert_helper.py new file mode 100644 index 00000000000..0f41b3514e7 --- /dev/null +++ b/lib/ansible/modules/crypto/acme/acme_challenge_cert_helper.py @@ -0,0 +1,264 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018 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 + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: acme_challenge_cert_helper +author: "Felix Fontein (@felixfontein)" +version_added: "2.7" +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(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." + - "The C(tls-alpn-01) implementation is based on + L(the draft-05 version of the specification,https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05)." +requirements: + - "cryptography >= 1.3" +options: + challenge: + description: + - "The challenge type." + required: yes + choices: + - tls-alpn-01 + challenge_data: + description: + - "The C(challenge_data) entry provided by M(acme_certificate) for the challenge." + required: yes + private_key_src: + description: + - "Path to a file containing the private key file to use for this challenge + certificate." + - "Mutually exclusive with C(private_key_content)." + private_key_content: + description: + - "Content of the private key to use for this challenge certificate." + - "Mutually exclusive with C(private_key_src)." +''' + +EXAMPLES = ''' +- name: Create challenges for a given CRT for sample.com + 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 + register: sample_com_challenge + +- name: Create certificates for challenges + 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 + with_items: "{{ sample_com_challenge.challenge_data }}" + 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 + with_items: "{{ sample_com_challenge_certs.results }}" + +- name: Create certificate for a given CSR for sample.com + 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 }}" +''' + +RETURN = ''' +domain: + description: + - "The domain the challenge is for." + returned: always + type: string +challenge_certificate: + description: + - "The challenge certificate in PEM format." + returned: always + type: string +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: string +''' + +from ansible.module_utils.acme import ( + ModuleFailException, + read_file, +) + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_bytes, to_text + +import base64 +import datetime +import sys + +try: + import cryptography + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.padding + import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.asymmetric.utils + import cryptography.x509 + import cryptography.x509.oid + from distutils.version import LooseVersion + HAS_CRYPTOGRAPHY = (LooseVersion(cryptography.__version__) >= LooseVersion('1.3')) + _cryptography_backend = cryptography.hazmat.backends.default_backend() +except ImportError as e: + HAS_CRYPTOGRAPHY = False + + +# Convert byte string to ASN1 encoded octet string +if sys.version_info[0] >= 3: + def encode_octet_string(octet_string): + if len(octet_string) >= 128: + raise ModuleFailException('Cannot handle octet strings with more than 128 bytes') + return bytes([0x4, len(octet_string)]) + octet_string +else: + def encode_octet_string(octet_string): + if len(octet_string) >= 128: + raise ModuleFailException('Cannot handle octet strings with more than 128 bytes') + return b'\x04' + chr(len(octet_string)) + octet_string + + +def main(): + module = AnsibleModule( + argument_spec=dict( + challenge=dict(required=True, choices=['tls-alpn-01'], type='str'), + challenge_data=dict(required=True, type='dict'), + private_key_src=dict(type='path'), + private_key_content=dict(type='str', no_log=True), + ), + required_one_of=( + ['private_key_src', 'private_key_content'], + ), + mutually_exclusive=( + ['private_key_src', 'private_key_content'], + ), + ) + if not HAS_CRYPTOGRAPHY: + module.fail(msg='cryptography >= 1.3 is required for this module.') + + try: + # Get parameters + challenge = module.params['challenge'] + challenge_data = module.params['challenge_data'] + + # Get hold of private key + private_key_content = module.params.get('private_key_content') + if private_key_content is None: + private_key_content = read_file(module.params['private_key_src']) + else: + private_key_content = to_bytes(private_key_content) + try: + private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key(private_key_content, password=None, backend=_cryptography_backend) + except Exception as e: + raise ModuleFailException('Error while loading private key: {0}'.format(e)) + + # Some common attributes + domain = to_text(challenge_data['resource']) + subject = issuer = cryptography.x509.Name([ + cryptography.x509.NameAttribute(cryptography.x509.oid.NameOID.COMMON_NAME, domain), + ]) + not_valid_before = datetime.datetime.utcnow() + not_valid_after = datetime.datetime.utcnow() + datetime.timedelta(days=10) + + # Generate regular self-signed certificate + regular_certificate = cryptography.x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + cryptography.x509.random_serial_number() + ).not_valid_before( + not_valid_before + ).not_valid_after( + not_valid_after + ).add_extension( + cryptography.x509.SubjectAlternativeName([cryptography.x509.DNSName(domain)]), + critical=False, + ).sign( + private_key, + cryptography.hazmat.primitives.hashes.SHA256(), + _cryptography_backend + ) + + # Process challenge + if challenge == 'tls-alpn-01': + value = base64.b64decode(challenge_data['resource_value']) + challenge_certificate = cryptography.x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + cryptography.x509.random_serial_number() + ).not_valid_before( + not_valid_before + ).not_valid_after( + not_valid_after + ).add_extension( + cryptography.x509.SubjectAlternativeName([cryptography.x509.DNSName(domain)]), + 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, + ).sign( + private_key, + cryptography.hazmat.primitives.hashes.SHA256(), + _cryptography_backend + ) + + module.exit_json( + changed=True, + domain=domain, + 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) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/acme_challenge_cert_helper/aliases b/test/integration/targets/acme_challenge_cert_helper/aliases new file mode 100644 index 00000000000..d7936330302 --- /dev/null +++ b/test/integration/targets/acme_challenge_cert_helper/aliases @@ -0,0 +1,2 @@ +shippable/cloud/group1 +cloud/acme diff --git a/test/integration/targets/acme_challenge_cert_helper/meta/main.yml b/test/integration/targets/acme_challenge_cert_helper/meta/main.yml new file mode 100644 index 00000000000..81d1e7e77a5 --- /dev/null +++ b/test/integration/targets/acme_challenge_cert_helper/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_acme diff --git a/test/integration/targets/acme_challenge_cert_helper/tasks/main.yml b/test/integration/targets/acme_challenge_cert_helper/tasks/main.yml new file mode 100644 index 00000000000..857485634cb --- /dev/null +++ b/test/integration/targets/acme_challenge_cert_helper/tasks/main.yml @@ -0,0 +1,25 @@ +--- +- block: + - name: Create ECC256 account key + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem + - name: Obtain cert 1 + include_tasks: obtain-cert.yml + vars: + select_crypto_backend: auto + certgen_title: Certificate 1 + certificate_name: cert-1 + key_type: rsa + rsa_bits: 2048 + subject_alt_name: "DNS:example.com" + subject_alt_name_critical: no + account_key: account-ec256 + challenge: tls-alpn-01 + challenge_alpn_tls: acme_challenge_cert_helper + modify_account: yes + deactivate_authzs: no + force: no + remaining_days: 10 + terms_agreed: yes + account_email: "example@example.org" + + when: openssl_version.stdout is version('1.0.0', '>=') or cryptography_version.stdout is version('1.5', '>=') diff --git a/test/integration/targets/acme_challenge_cert_helper/tasks/obtain-cert.yml b/test/integration/targets/acme_challenge_cert_helper/tasks/obtain-cert.yml new file mode 120000 index 00000000000..532df9452ea --- /dev/null +++ b/test/integration/targets/acme_challenge_cert_helper/tasks/obtain-cert.yml @@ -0,0 +1 @@ +../../setup_acme/tasks/obtain-cert.yml \ No newline at end of file diff --git a/test/integration/targets/setup_acme/tasks/obtain-cert.yml b/test/integration/targets/setup_acme/tasks/obtain-cert.yml index 268285fd9df..f89e212ff78 100644 --- a/test/integration/targets/setup_acme/tasks/obtain-cert.yml +++ b/test/integration/targets/setup_acme/tasks/obtain-cert.yml @@ -85,7 +85,25 @@ body: "{{ item.value }}" with_dict: "{{ challenge_data.challenge_data_dns }}" when: "challenge_data is changed and challenge == 'dns-01'" -- name: ({{ certgen_title }}) Create TLS ALPN challenges +- name: ({{ certgen_title }}) Create TLS ALPN challenges (acm_challenge_cert_helper) + acme_challenge_cert_helper: + challenge: tls-alpn-01 + challenge_data: "{{ item.value['tls-alpn-01'] }}" + private_key_src: "{{ output_dir }}/{{ certificate_name }}.key" + with_dict: "{{ challenge_data.challenge_data }}" + register: tls_alpn_challenges + when: "challenge_data is changed and challenge == 'tls-alpn-01' and (challenge_alpn_tls is defined and challenge_alpn_tls == 'acme_challenge_cert_helper')" +- name: ({{ certgen_title }}) Set TLS ALPN challenges (acm_challenge_cert_helper) + uri: + url: "http://{{ acme_host }}:5000/tls-alpn/{{ item.domain }}/certificate-and-key" + method: PUT + body_format: raw + body: "{{ item.challenge_certificate }}\n{{ lookup('file', output_dir ~ '/' ~ certificate_name ~ '.key') }}" + headers: + content-type: "application/pem-certificate-chain" + with_items: "{{ tls_alpn_challenges.results }}" + when: "challenge_data is changed and challenge == 'tls-alpn-01' and (challenge_alpn_tls is defined and challenge_alpn_tls == 'acme_challenge_cert_helper')" +- name: ({{ certgen_title }}) Create TLS ALPN challenges (der-value-b64) uri: url: "http://{{ acme_host }}:5000/tls-alpn/{{ item.value['tls-alpn-01'].resource }}/der-value-b64" method: PUT @@ -94,7 +112,7 @@ headers: content-type: "application/octet-stream" with_dict: "{{ challenge_data.challenge_data }}" - when: "challenge_data is changed and challenge == 'tls-alpn-01'" + when: "challenge_data is changed and challenge == 'tls-alpn-01' and (challenge_alpn_tls is not defined or challenge_alpn_tls == 'der-value-b64')" ## ACME STEP 2 ################################################################################ - name: ({{ certgen_title }}) Obtain cert, step 2 acme_certificate: