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)
        new_cert = {
            "resource": "new-cert",
            "csr": nopad_b64(csr),
        }
        result, info = self.account.send_signed_request(
            self.directory['new-cert'], new_cert)

        chain = []
        if 'link' in info:
            link = info['link']
            parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link)
            if parsed_link and parsed_link.group(2) == "up":
                chain_link = parsed_link.group(1)
                chain_result, chain_info = self.account.get_request(
                    chain_link, parse_json_result=False)
                if chain_info['status'] in [200, 201]:
                    chain = [self._der_to_pem(chain_result)]

        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
            }
Example #2
0
    def _get_challenge_data(self, auth, domain):
        '''
        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']:
            type = challenge['type']
            token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
            keyauthorization = self.account.get_keyauthorization(token)

            if type == 'http-01':
                # https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-8.3
                resource = '.well-known/acme-challenge/' + token
                data[type] = {'resource': resource, 'resource_value': keyauthorization}
            elif type == 'dns-01':
                # https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-8.4
                resource = '_acme-challenge'
                value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest())
                record = (resource + domain[1:]) if domain.startswith('*.') else (resource + '.' + domain)
                data[type] = {'resource': resource, 'resource_value': value, 'record': record}
            elif type == 'tls-alpn-01':
                # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3
                resource = domain
                value = base64.b64encode(hashlib.sha256(to_bytes(keyauthorization)).digest())
                data[type] = {'resource': resource, 'resource_value': value}
            else:
                continue

        return data
    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)
        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))

        order = info['location']

        status = result['status']
        while status not in ['valid', 'invalid']:
            time.sleep(2)
            result, dummy = self.account.get_request(order)
            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']
Example #4
0
    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
        '''
        openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-outform", "DER"]
        dummy, out, dummy = self.module.run_command(openssl_csr_cmd, check_rc=True)

        new_cert = {
            "resource": "new-cert",
            "csr": nopad_b64(to_bytes(out)),
        }
        result, info = self.account.send_signed_request(self.directory['new-cert'], new_cert)

        chain = []
        if 'link' in info:
            link = info['link']
            parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link)
            if parsed_link and parsed_link.group(2) == "up":
                chain_link = parsed_link.group(1)
                chain_result, chain_info = fetch_url(self.module, chain_link, method='GET')
                if chain_info['status'] in [200, 201]:
                    chain = [self._der_to_pem(chain_result.read())]

        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}
Example #5
0
    def _finalize_cert(self):
        '''
        Create a new certificate based on the csr.
        Return the certificate object as dict
        https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.4
        '''
        openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-outform", "DER"]
        dummy, out, dummy = self.module.run_command(openssl_csr_cmd, check_rc=True)

        new_cert = {
            "csr": nopad_b64(to_bytes(out)),
        }
        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))

        order = info['location']

        status = result['status']
        while status not in ['valid', 'invalid']:
            time.sleep(2)
            result = simple_get(self.module, order)
            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 _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)
        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]:
                    chain.clear()
                    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}
Example #7
0
    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://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#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
Example #8
0
    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://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3
                if identifier_type == 'ip':
                    if ':' in identifier:
                        # IPv6 address: use reverse IP6.ARPA mapping (RFC3596)
                        i = identifier.find('::')
                        if i >= 0:
                            nibbles = [nibble for nibble in identifier[:i].split(':') if nibble]
                            suffix = [nibble for nibble in identifier[i + 1:].split(':') if nibble]
                            if len(nibbles) + len(suffix) < 8:
                                nibbles.extend(['0'] * (8 - len(nibbles) - len(suffix)))
                            nibbles.extend(suffix)
                        else:
                            nibbles = identifier.split(':')
                        resource = []
                        for nibble in reversed(nibbles):
                            nibble = '0' * (4 - len(nibble)) + nibble.lower()
                            for octet in reversed(nibble):
                                resource.append(octet)
                        resource = '.'.join(resource) + '.ip6.arpa.'
                    else:
                        # IPv4 address: use reverse IN-ADDR.ARPA mapping (RFC1034)
                        resource = '.'.join(reversed(identifier.split('.'))) + '.in-addr.arpa.'
                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
Example #9
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            account_uri=dict(required=False, type='str'),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            private_key_src=dict(type='path'),
            private_key_content=dict(type='str', no_log=True),
            certificate=dict(required=True, type='path'),
            revoke_reason=dict(required=False, type='int'),
            select_crypto_backend=dict(
                required=False,
                choices=['auto', 'openssl', 'cryptography'],
                default='auto',
                type='str'),
        ),
        required_one_of=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        mutually_exclusive=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        supports_check_mode=False,
    )
    set_crypto_backend(module)

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            '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.')

    try:
        account = ACMEAccount(module)
        # Load certificate
        certificate = pem_to_der(module.params.get('certificate'))
        certificate = nopad_b64(certificate)
        # Construct payload
        payload = {'certificate': certificate}
        if module.params.get('revoke_reason') is not None:
            payload['reason'] = module.params.get('revoke_reason')
        # Determine endpoint
        if module.params.get('acme_version') == 1:
            endpoint = account.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = account.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)
            # 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)
        else:
            # Step 1: get hold of account URI
            changed = account.init_account(
                [],
                allow_creation=False,
                update_contact=False,
            )
            if changed:
                raise AssertionError('Unwanted account change')
            # Step 2: sign revokation request with account key
            result, info = account.send_signed_request(endpoint, payload)
        if info['status'] != 200:
            already_revoked = False
            # Standarized error in draft 14 (https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-7.6)
            if result.get(
                    'type') == 'urn:ietf:params:acme:error:alreadyRevoked':
                already_revoked = True
            else:
                # Hack for Boulder errors
                if module.params.get('acme_version') == 1:
                    error_type = 'urn:acme:error:malformed'
                else:
                    error_type = 'urn:ietf:params:acme:error:malformed'
                if result.get('type') == error_type and result.get(
                        'detail') == 'Certificate already revoked':
                    # Fallback: boulder returns this in case the certificate was already revoked.
                    already_revoked = True
            # If we know the certificate was already revoked, we don't fail,
            # 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))
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)
Example #10
0
def test_nopad_b64(value, result):
    assert nopad_b64(value.encode('utf-8')) == result
Example #11
0
def main():
    argument_spec = get_default_argspec()
    argument_spec.update(dict(
        private_key_src=dict(type='path'),
        private_key_content=dict(type='str', no_log=True),
        certificate=dict(type='path', required=True),
        revoke_reason=dict(type='int'),
    ))
    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=(
            ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
        ),
        mutually_exclusive=(
            ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
        ),
        supports_check_mode=False,
    )
    handle_standard_module_arguments(module)

    try:
        account = ACMEAccount(module)
        # Load certificate
        certificate = pem_to_der(module.params.get('certificate'))
        certificate = nopad_b64(certificate)
        # Construct payload
        payload = {
            'certificate': certificate
        }
        if module.params.get('revoke_reason') is not None:
            payload['reason'] = module.params.get('revoke_reason')
        # Determine endpoint
        if module.params.get('acme_version') == 1:
            endpoint = account.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = account.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)
            # 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)
        else:
            # Step 1: get hold of account URI
            created, account_data = account.setup_account(allow_creation=False)
            if created:
                raise AssertionError('Unwanted account creation')
            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)
        if info['status'] != 200:
            already_revoked = False
            # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6)
            if result.get('type') == 'urn:ietf:params:acme:error:alreadyRevoked':
                already_revoked = True
            else:
                # Hack for Boulder errors
                if module.params.get('acme_version') == 1:
                    error_type = 'urn:acme:error:malformed'
                else:
                    error_type = 'urn:ietf:params:acme:error:malformed'
                if result.get('type') == error_type and result.get('detail') == 'Certificate already revoked':
                    # Fallback: boulder returns this in case the certificate was already revoked.
                    already_revoked = True
            # If we know the certificate was already revoked, we don't fail,
            # 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))
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)
Example #12
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            private_key_src=dict(type='path'),
            private_key_content=dict(type='str', no_log=True),
            certificate=dict(required=True, type='path'),
            revoke_reason=dict(required=False, type='int'),
        ),
        required_one_of=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        mutually_exclusive=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        supports_check_mode=False,
    )

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            '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.')

    try:
        account = ACMEAccount(module)
        # Load certificate
        certificate_lines = []
        try:
            with open(module.params.get('certificate'), "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 certificate file: %s" %
                                      to_native(err),
                                      exception=traceback.format_exc())
        certificate = nopad_b64(base64.b64decode(''.join(certificate_lines)))
        # Construct payload
        payload = {'certificate': certificate}
        if module.params.get('revoke_reason') is not None:
            payload['reason'] = module.params.get('revoke_reason')
        # Determine endpoint
        if module.params.get('acme_version') == 1:
            endpoint = account.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = account.directory['revokeCert']
        # Get hold of private key (if available) and make sure it comes from disk
        private_key = module.params.get('private_key_src')
        if module.params.get('private_key_content') is not 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(
                    module.params.get('private_key_content').encode('utf-8'))
                private_key = tmpsrc
            except Exception as err:
                try:
                    f.close()
                except Exception as e:
                    pass
                raise ModuleFailException(
                    "failed to create temporary content file: %s" %
                    to_native(err),
                    exception=traceback.format_exc())
            f.close()
        # Revoke certificate
        if private_key:
            # Step 1: load and parse private key
            error, private_key_data = account.parse_account_key(private_key)
            if error:
                raise ModuleFailException(
                    "error while parsing private key: %s" % error)
            # 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=private_key,
                key_data=private_key_data,
                jws_header=jws_header)
        else:
            # Step 1: get hold of account URI
            changed = account.init_account(
                [],
                allow_creation=False,
                update_contact=False,
            )
            if changed:
                raise AssertionError('Unwanted account change')
            # Step 2: sign revokation request with account key
            result, info = account.send_signed_request(endpoint, payload)
        if info['status'] != 200:
            if module.params.get('acme_version') == 1:
                error_type = 'urn:acme:error:malformed'
            else:
                error_type = 'urn:ietf:params:acme:error:malformed'
            if result.get('type') == error_type and result.get(
                    'detail') == 'Certificate already revoked':
                # Fallback: boulder returns this in case the certificate was already revoked.
                module.exit_json(changed=False)
            raise ModuleFailException(
                'Error revoking certificate: {0} {1}'.format(
                    info['status'], result))
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)