Ejemplo n.º 1
0
    def download(cls, client, url):
        content, info = client.get_request(
            url,
            parse_json_result=False,
            headers={'Accept': 'application/pem-certificate-chain'})

        if not content or not info['content-type'].startswith(
                'application/pem-certificate-chain'):
            raise ModuleFailException(
                "Cannot download certificate chain from {0}, as content type is not application/pem-certificate-chain: {1} (headers: {2})"
                .format(url, content, info))

        result = cls(url)

        # Parse data
        certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True)
        if certs:
            result.cert = certs[0]
            result.chain = certs[1:]

        process_links(
            info, lambda link, relation: result._process_links(
                client, link, relation))

        if result.cert is None:
            raise ModuleFailException(
                "Failed to parse certificate chain download from {0}: {1} (headers: {2})"
                .format(url, content, info))

        return result
Ejemplo n.º 2
0
def pem_to_der(pem_filename=None, pem_content=None):
    '''
    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 = []
    if pem_content is not None:
        lines = pem_content.splitlines()
    elif pem_filename is not None:
        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())
    else:
        raise ModuleFailException(
            'One of pem_filename and pem_content must be provided')
    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))
Ejemplo n.º 3
0
    def __init__(self, module, backend):
        self.module = module
        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')
        self.client = ACMEClient(module, backend)
        self.account = ACMEAccount(self.client)
        self.directory = self.client.directory
        self.data = module.params['data']
        self.authorizations = None
        self.cert_days = -1
        self.order = None
        self.order_uri = self.data.get('order_uri') if self.data else None
        self.all_chains = None
        self.select_chain_matcher = []

        if self.module.params['select_chain']:
            for criterium_idx, criterium in enumerate(self.module.params['select_chain']):
                self.select_chain_matcher.append(
                    self.client.backend.create_chain_matcher(
                        Criterium(criterium, index=criterium_idx)))

        # Make sure account exists
        modify_account = module.params['modify_account']
        if modify_account or self.version > 1:
            contact = []
            if module.params['account_email']:
                contact.append('mailto:' + module.params['account_email'])
            created, account_data = self.account.setup_account(
                contact,
                agreement=module.params.get('agreement'),
                terms_agreed=module.params.get('terms_agreed'),
                allow_creation=modify_account,
            )
            if account_data is None:
                raise ModuleFailException(msg='Account does not exist or is deactivated.')
            updated = False
            if not created and account_data and modify_account:
                updated, account_data = self.account.update_account(account_data, contact)
            self.changed = created or updated
        else:
            # This happens if modify_account is False and the ACME v1
            # protocol is used. In this case, we do not call setup_account()
            # to avoid accidental creation of an account. This is OK
            # since for ACME v1, the account URI is not needed to send a
            # signed ACME request.
            pass

        if self.csr is not None and not os.path.exists(self.csr):
            raise ModuleFailException("CSR %s not found" % (self.csr))

        # Extract list of identifiers from CSR
        self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
Ejemplo n.º 4
0
 def get_challenges_data(self, first_step):
     '''
     Get challenge details for the chosen challenge type.
     Return a tuple of generic challenge details, and specialized DNS challenge details.
     '''
     # Get general challenge data
     data = {}
     for type_identifier, authz in self.authorizations.items():
         identifier_type, identifier = split_identifier(type_identifier)
         # Skip valid authentications: their challenges are already valid
         # and do not need to be returned
         if authz.status == 'valid':
             continue
         # We drop the type from the key to preserve backwards compatibility
         data[identifier] = authz.get_challenge_data(self.client)
         if first_step and self.challenge not in data[identifier]:
             raise ModuleFailException(
                 "Found no challenge of type '{0}' for identifier {1}!".
                 format(self.challenge, type_identifier))
     # Get DNS challenge data
     data_dns = {}
     if self.challenge == 'dns-01':
         for identifier, challenges in data.items():
             if self.challenge in challenges:
                 values = data_dns.get(challenges[self.challenge]['record'])
                 if values is None:
                     values = []
                     data_dns[challenges[self.challenge]['record']] = values
                 values.append(challenges[self.challenge]['resource_value'])
     return data, data_dns
Ejemplo n.º 5
0
def split_identifier(identifier):
    parts = identifier.split(':', 1)
    if len(parts) != 2:
        raise ModuleFailException(
            'Identifier "{identifier}" is not of the form <type>:<identifier>'.
            format(identifier=identifier))
    return parts
Ejemplo n.º 6
0
def read_file(fn, mode='b'):
    try:
        with open(fn, 'r' + mode) as f:
            return f.read()
    except Exception as e:
        raise ModuleFailException('Error while reading file "{0}": {1}'.format(
            fn, e))
Ejemplo n.º 7
0
    def setup_account(self,
                      contact=None,
                      agreement=None,
                      terms_agreed=False,
                      allow_creation=True,
                      remove_account_uri_if_not_exists=False,
                      external_account_binding=None):
        '''
        Detect or create an account on the ACME server. For ACME v1,
        as the only way (without knowing an account URI) to test if an
        account exists is to try and create one with the provided account
        key, this method will always result in an account being present
        (except on error situations). For ACME v2, a new account will
        only be created if ``allow_creation`` is set to True.

        For ACME v2, ``check_mode`` is fully respected. For ACME v1, the
        account might be created if it does not yet exist.

        Return a pair ``(created, account_data)``. Here, ``created`` will
        be ``True`` in case the account was created or would be created
        (check mode). ``account_data`` will be the current account data,
        or ``None`` if the account does not exist.

        The account URI will be stored in ``client.account_uri``; if it is ``None``,
        the account does not exist.

        If specified, ``external_account_binding`` should be a dictionary
        with keys ``kid``, ``alg`` and ``key``
        (https://tools.ietf.org/html/rfc8555#section-7.3.4).

        https://tools.ietf.org/html/rfc8555#section-7.3
        '''

        if self.client.account_uri is not None:
            created = False
            # Verify that the account key belongs to the URI.
            # (If update_contact is True, this will be done below.)
            account_data = self.get_account_data()
            if account_data is None:
                if remove_account_uri_if_not_exists and not allow_creation:
                    self.client.account_uri = None
                else:
                    raise ModuleFailException(
                        "Account is deactivated or does not exist!")
        else:
            created, account_data = self._new_reg(
                contact,
                agreement=agreement,
                terms_agreed=terms_agreed,
                allow_creation=allow_creation
                and not self.client.module.check_mode,
                external_account_binding=external_account_binding,
            )
            if self.client.module.check_mode and self.client.account_uri is None and allow_creation:
                created = True
                account_data = {'contact': contact or []}
        return created, account_data
Ejemplo n.º 8
0
def write_file(module, dest, content):
    '''
    Write content to destination file dest, only if the content
    has changed.
    '''
    changed = False
    # create a tempfile
    fd, tmpsrc = tempfile.mkstemp(text=False)
    f = os.fdopen(fd, 'wb')
    try:
        f.write(content)
    except Exception as err:
        try:
            f.close()
        except Exception as dummy:
            pass
        os.remove(tmpsrc)
        raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
    f.close()
    checksum_src = None
    checksum_dest = None
    # raise an error if there is no tmpsrc file
    if not os.path.exists(tmpsrc):
        try:
            os.remove(tmpsrc)
        except Exception as dummy:
            pass
        raise ModuleFailException("Source %s does not exist" % (tmpsrc))
    if not os.access(tmpsrc, os.R_OK):
        os.remove(tmpsrc)
        raise ModuleFailException("Source %s not readable" % (tmpsrc))
    checksum_src = module.sha1(tmpsrc)
    # check if there is no dest file
    if os.path.exists(dest):
        # raise an error if copy has no permission on dest
        if not os.access(dest, os.W_OK):
            os.remove(tmpsrc)
            raise ModuleFailException("Destination %s not writable" % (dest))
        if not os.access(dest, os.R_OK):
            os.remove(tmpsrc)
            raise ModuleFailException("Destination %s not readable" % (dest))
        checksum_dest = module.sha1(dest)
    else:
        dirname = os.path.dirname(dest) or '.'
        if not os.access(dirname, os.W_OK):
            os.remove(tmpsrc)
            raise ModuleFailException("Destination dir %s not writable" % (dirname))
    if checksum_src != checksum_dest:
        try:
            shutil.copyfile(tmpsrc, dest)
            changed = True
        except Exception as err:
            os.remove(tmpsrc)
            raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
    os.remove(tmpsrc)
    return changed
Ejemplo n.º 9
0
    def __init__(self, module, account):
        self.module = module
        self.directory_root = module.params['acme_directory']
        self.version = module.params['acme_version']

        self.directory, dummy = account.get_request(self.directory_root,
                                                    get_only=True)

        # Check whether self.version matches what we expect
        if self.version == 1:
            for key in ('new-reg', 'new-authz', 'new-cert'):
                if key not in self.directory:
                    raise ModuleFailException(
                        "ACME directory does not seem to follow protocol ACME v1"
                    )
        if self.version == 2:
            for key in ('newNonce', 'newAccount', 'newOrder'):
                if key not in self.directory:
                    raise ModuleFailException(
                        "ACME directory does not seem to follow protocol ACME v2"
                    )
Ejemplo n.º 10
0
    def get_certificate(self):
        '''
        Request a new certificate and write it to the destination file.
        First verifies whether all authorizations are valid; if not, aborts
        with an error.
        '''
        for identifier_type, identifier in self.identifiers:
            authz = self.authorizations.get(combine_identifier(identifier_type, identifier))
            if authz is None:
                raise ModuleFailException('Found no authorization information for "{identifier}"!'.format(
                    identifier=combine_identifier(identifier_type, identifier)))
            if authz.status != 'valid':
                authz.raise_error('Status is "{status}" and not "valid"'.format(status=authz.status), module=self.module)

        if self.version == 1:
            cert = retrieve_acme_v1_certificate(self.client, pem_to_der(self.csr, self.csr_content))
        else:
            self.order.finalize(self.client, pem_to_der(self.csr, self.csr_content))
            cert = CertificateChain.download(self.client, self.order.certificate_uri)
            if self.module.params['retrieve_all_alternates'] or self.select_chain_matcher:
                # Retrieve alternate chains
                alternate_chains = self.download_alternate_chains(cert)

                # Prepare return value for all alternate chains
                if self.module.params['retrieve_all_alternates']:
                    self.all_chains = [cert.to_json()]
                    for alt_chain in alternate_chains:
                        self.all_chains.append(alt_chain.to_json())

                # Try to select alternate chain depending on criteria
                if self.select_chain_matcher:
                    matching_chain = self.find_matching_chain([cert] + alternate_chains)
                    if matching_chain:
                        cert = matching_chain
                    else:
                        self.module.debug('Found no matching alternative chain')

        if cert.cert is not None:
            pem_cert = cert.cert
            chain = cert.chain

            if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')):
                self.cert_days = self.client.backend.get_cert_days(self.dest)
                self.changed = True

            if self.fullchain_dest and write_file(self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')):
                self.cert_days = self.client.backend.get_cert_days(self.fullchain_dest)
                self.changed = True

            if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')):
                self.changed = True
    def call_validate(self, client, challenge_type, wait=True):
        '''
        Validate the authorization provided in the auth dict. Returns True
        when the validation was successful and False when it was not.
        '''
        challenge = self.find_challenge(challenge_type)
        if challenge is None:
            raise ModuleFailException('Found no challenge of type "{challenge}" for identifier {identifier}!'.format(
                challenge=challenge_type,
                identifier=self.combined_identifier,
            ))

        challenge.call_validate(client)

        if not wait:
            return self.status == 'valid'
        return self.wait_for_validation(client, challenge_type)
Ejemplo n.º 12
0
 def start_challenges(self):
     '''
     Create new authorizations for all identifiers of the CSR,
     respectively start a new order for ACME v2.
     '''
     self.authorizations = {}
     if self.version == 1:
         for identifier_type, identifier in self.identifiers:
             if identifier_type != 'dns':
                 raise ModuleFailException('ACME v1 only supports DNS identifiers!')
         for identifier_type, identifier in self.identifiers:
             authz = Authorization.create(self.client, identifier_type, identifier)
             self.authorizations[authz.combined_identifier] = authz
     else:
         self.order = Order.create(self.client, self.identifiers)
         self.order_uri = self.order.url
         self.order.load_authorizations(self.client)
         self.authorizations.update(self.order.authorizations)
     self.changed = True
Ejemplo n.º 13
0
    def sign_request(self, protected, payload, key_data, encode_payload=True):
        '''
        Signs an ACME request.
        '''
        try:
            if payload is None:
                # POST-as-GET
                payload64 = ''
            else:
                # POST
                if encode_payload:
                    payload = self.module.jsonify(payload).encode('utf8')
                payload64 = nopad_b64(to_bytes(payload))
            protected64 = nopad_b64(
                self.module.jsonify(protected).encode('utf8'))
        except Exception as e:
            raise ModuleFailException(
                "Failed to encode payload / headers as JSON: {0}".format(e))

        return self.backend.sign(payload64, protected64, key_data)
Ejemplo n.º 14
0
 def get_account_data(self):
     '''
     Retrieve account information. Can only be called when the account
     URI is already known (such as after calling setup_account).
     Return None if the account was deactivated, or a dict otherwise.
     '''
     if self.client.account_uri is None:
         raise ModuleFailException("Account URI unknown")
     if self.client.version == 1:
         data = {}
         data['resource'] = 'reg'
         result, info = self.client.send_signed_request(
             self.client.account_uri, data, fail_on_error=False)
     else:
         # try POST-as-GET first (draft-15 or newer)
         data = None
         result, info = self.client.send_signed_request(
             self.client.account_uri, data, fail_on_error=False)
         # check whether that failed with a malformed request error
         if info['status'] >= 400 and result.get(
                 'type') == 'urn:ietf:params:acme:error:malformed':
             # retry as a regular POST (with no changed data) for pre-draft-15 ACME servers
             data = {}
             result, info = self.client.send_signed_request(
                 self.client.account_uri, data, fail_on_error=False)
     if info['status'] in (400, 403) and result.get(
             'type') == 'urn:ietf:params:acme:error:unauthorized':
         # Returned when account is deactivated
         return None
     if info['status'] in (400, 404) and result.get(
             'type') == 'urn:ietf:params:acme:error:accountDoesNotExist':
         # Returned when account does not exist
         return None
     if info['status'] < 200 or info['status'] >= 300:
         raise ACMEProtocolException(self.client.module,
                                     msg='Error retrieving account data',
                                     info=info,
                                     content_json=result)
     return result
Ejemplo n.º 15
0
    def __init__(self, module, backend):
        # Set to true to enable logging of all signed requests
        self._debug = False

        self.module = module
        self.backend = backend
        self.version = module.params['acme_version']
        # account_key path and content are mutually exclusive
        self.account_key_file = module.params['account_key_src']
        self.account_key_content = module.params['account_key_content']
        self.account_key_passphrase = module.params['account_key_passphrase']

        # Grab account URI from module parameters.
        # Make sure empty string is treated as None.
        self.account_uri = module.params.get('account_uri') or None

        self.account_key_data = None
        self.account_jwk = None
        self.account_jws_header = None
        if self.account_key_file is not None or self.account_key_content is not None:
            try:
                self.account_key_data = self.parse_key(
                    key_file=self.account_key_file,
                    key_content=self.account_key_content,
                    passphrase=self.account_key_passphrase)
            except KeyParsingError as e:
                raise ModuleFailException(
                    "Error while parsing account key: {msg}".format(msg=e.msg))
            self.account_jwk = self.account_key_data['jwk']
            self.account_jws_header = {
                "alg": self.account_key_data['alg'],
                "jwk": self.account_jwk,
            }
            if self.account_uri:
                # Make sure self.account_jws_header is updated
                self.set_account_uri(self.account_uri)

        self.directory = ACMEDirectory(module, self)
Ejemplo n.º 16
0
def main():
    argument_spec = get_default_argspec()
    argument_spec.update(dict(
        terms_agreed=dict(type='bool', default=False),
        state=dict(type='str', required=True, choices=['absent', 'present', 'changed_key']),
        allow_creation=dict(type='bool', default=True),
        contact=dict(type='list', elements='str', default=[]),
        new_account_key_src=dict(type='path'),
        new_account_key_content=dict(type='str', no_log=True),
        new_account_key_passphrase=dict(type='str', no_log=True),
        external_account_binding=dict(type='dict', options=dict(
            kid=dict(type='str', required=True),
            alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']),
            key=dict(type='str', required=True, no_log=True),
        ))
    ))
    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=(
            ['account_key_src', 'account_key_content'],
        ),
        mutually_exclusive=(
            ['account_key_src', 'account_key_content'],
            ['new_account_key_src', 'new_account_key_content'],
        ),
        required_if=(
            # Make sure that for state == changed_key, one of
            # new_account_key_src and new_account_key_content are specified
            ['state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True],
        ),
        supports_check_mode=True,
    )
    backend = create_backend(module, True)

    if module.params['external_account_binding']:
        # Make sure padding is there
        key = module.params['external_account_binding']['key']
        if len(key) % 4 != 0:
            key = key + ('=' * (4 - (len(key) % 4)))
        # Make sure key is Base64 encoded
        try:
            base64.urlsafe_b64decode(key)
        except Exception as e:
            module.fail_json(msg='Key for external_account_binding must be Base64 URL encoded (%s)' % e)
        module.params['external_account_binding']['key'] = key

    try:
        client = ACMEClient(module, backend)
        account = ACMEAccount(client)
        changed = False
        state = module.params.get('state')
        diff_before = {}
        diff_after = {}
        if state == 'absent':
            created, account_data = account.setup_account(allow_creation=False)
            if account_data:
                diff_before = dict(account_data)
                diff_before['public_account_key'] = client.account_key_data['jwk']
            if created:
                raise AssertionError('Unwanted account creation')
            if account_data is not None:
                # Account is not yet deactivated
                if not module.check_mode:
                    # Deactivate it
                    payload = {
                        'status': 'deactivated'
                    }
                    result, info = client.send_signed_request(
                        client.account_uri, payload, error_msg='Failed to deactivate account', expected_status_codes=[200])
                changed = True
        elif state == 'present':
            allow_creation = module.params.get('allow_creation')
            contact = [str(v) for v in module.params.get('contact')]
            terms_agreed = module.params.get('terms_agreed')
            external_account_binding = module.params.get('external_account_binding')
            created, account_data = account.setup_account(
                contact,
                terms_agreed=terms_agreed,
                allow_creation=allow_creation,
                external_account_binding=external_account_binding,
            )
            if account_data is None:
                raise ModuleFailException(msg='Account does not exist or is deactivated.')
            if created:
                diff_before = {}
            else:
                diff_before = dict(account_data)
                diff_before['public_account_key'] = client.account_key_data['jwk']
            updated = False
            if not created:
                updated, account_data = account.update_account(account_data, contact)
            changed = created or updated
            diff_after = dict(account_data)
            diff_after['public_account_key'] = client.account_key_data['jwk']
        elif state == 'changed_key':
            # Parse new account key
            try:
                new_key_data = client.parse_key(
                    module.params.get('new_account_key_src'),
                    module.params.get('new_account_key_content'),
                    passphrase=module.params.get('new_account_key_passphrase'),
                )
            except KeyParsingError as e:
                raise ModuleFailException("Error while parsing new account key: {msg}".format(msg=e.msg))
            # Verify that the account exists and has not been deactivated
            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.')
            diff_before = dict(account_data)
            diff_before['public_account_key'] = client.account_key_data['jwk']
            # Now we can start the account key rollover
            if not module.check_mode:
                # Compose inner signed message
                # https://tools.ietf.org/html/rfc8555#section-7.3.5
                url = client.directory['keyChange']
                protected = {
                    "alg": new_key_data['alg'],
                    "jwk": new_key_data['jwk'],
                    "url": url,
                }
                payload = {
                    "account": client.account_uri,
                    "newKey": new_key_data['jwk'],  # specified in draft 12 and older
                    "oldKey": client.account_jwk,  # specified in draft 13 and newer
                }
                data = client.sign_request(protected, payload, new_key_data)
                # Send request and verify result
                result, info = client.send_signed_request(
                    url, data, error_msg='Failed to rollover account key', expected_status_codes=[200])
                if module._diff:
                    client.account_key_data = new_key_data
                    client.account_jws_header['alg'] = new_key_data['alg']
                    diff_after = account.get_account_data()
            elif module._diff:
                # Kind of fake diff_after
                diff_after = dict(diff_before)
            diff_after['public_account_key'] = new_key_data['jwk']
            changed = True
        result = {
            'changed': changed,
            'account_uri': client.account_uri,
        }
        if module._diff:
            result['diff'] = {
                'before': diff_before,
                'after': diff_after,
            }
        module.exit_json(**result)
    except ModuleFailException as e:
        e.do_fail(module)
 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(type='str', required=True, choices=['tls-alpn-01']),
            challenge_data=dict(type='dict', required=True),
            private_key_src=dict(type='path'),
            private_key_content=dict(type='str', no_log=True),
            private_key_passphrase=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_json(msg=missing_required_lib('cryptography >= 1.3'),
                         exception=CRYPTOGRAPHY_IMP_ERR)

    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')
        private_key_passphrase = module.params.get('private_key_passphrase')
        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=to_bytes(private_key_passphrase)
                if private_key_passphrase is not None else 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'])
        identifier_type, identifier = to_text(
            challenge_data.get('resource_original',
                               'dns:' + challenge_data['resource'])).split(
                                   ':', 1)
        subject = issuer = cryptography.x509.Name([])
        not_valid_before = datetime.datetime.utcnow()
        not_valid_after = datetime.datetime.utcnow() + datetime.timedelta(
            days=10)
        if identifier_type == 'dns':
            san = cryptography.x509.DNSName(identifier)
        elif identifier_type == 'ip':
            san = cryptography.x509.IPAddress(ipaddress.ip_address(identifier))
        else:
            raise ModuleFailException(
                'Unsupported identifier type "{0}"'.format(identifier_type))

        # 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([san]),
                            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([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,
                            ).sign(
                                private_key,
                                cryptography.hazmat.primitives.hashes.SHA256(),
                                _cryptography_backend)

        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)
Ejemplo n.º 19
0
    def _new_reg(self,
                 contact=None,
                 agreement=None,
                 terms_agreed=False,
                 allow_creation=True,
                 external_account_binding=None):
        '''
        Registers a new ACME account. Returns a pair ``(created, data)``.
        Here, ``created`` is ``True`` if the account was created and
        ``False`` if it already existed (e.g. it was not newly created),
        or does not exist. In case the account was created or exists,
        ``data`` contains the account data; otherwise, it is ``None``.

        If specified, ``external_account_binding`` should be a dictionary
        with keys ``kid``, ``alg`` and ``key``
        (https://tools.ietf.org/html/rfc8555#section-7.3.4).

        https://tools.ietf.org/html/rfc8555#section-7.3
        '''
        contact = contact or []

        if self.client.version == 1:
            new_reg = {'resource': 'new-reg', 'contact': contact}
            if agreement:
                new_reg['agreement'] = agreement
            else:
                new_reg['agreement'] = self.client.directory['meta'][
                    'terms-of-service']
            if external_account_binding is not None:
                raise ModuleFailException(
                    'External account binding is not supported for ACME v1')
            url = self.client.directory['new-reg']
        else:
            if (external_account_binding is not None
                    or self.client.directory['meta'].get(
                        'externalAccountRequired')) and allow_creation:
                # Some ACME servers such as ZeroSSL do not like it when you try to register an existing account
                # and provide external_account_binding credentials. Thus we first send a request with allow_creation=False
                # to see whether the account already exists.

                # Note that we pass contact here: ZeroSSL does not accept regisration calls without contacts, even
                # if onlyReturnExisting is set to true.
                created, data = self._new_reg(contact=contact,
                                              allow_creation=False)
                if data:
                    # An account already exists! Return data
                    return created, data
                # An account does not yet exist. Try to create one next.

            new_reg = {'contact': contact}
            if not allow_creation:
                # https://tools.ietf.org/html/rfc8555#section-7.3.1
                new_reg['onlyReturnExisting'] = True
            if terms_agreed:
                new_reg['termsOfServiceAgreed'] = True
            url = self.client.directory['newAccount']
            if external_account_binding is not None:
                new_reg['externalAccountBinding'] = self.client.sign_request(
                    {
                        'alg': external_account_binding['alg'],
                        'kid': external_account_binding['kid'],
                        'url': url,
                    }, self.client.account_jwk,
                    self.client.backend.create_mac_key(
                        external_account_binding['alg'],
                        external_account_binding['key']))
            elif self.client.directory['meta'].get(
                    'externalAccountRequired') and allow_creation:
                raise ModuleFailException(
                    'To create an account, an external account binding must be specified. '
                    'Use the acme_account module with the external_account_binding option.'
                )

        result, info = self.client.send_signed_request(url,
                                                       new_reg,
                                                       fail_on_error=False)

        if info['status'] in ([200, 201]
                              if self.client.version == 1 else [201]):
            # Account did not exist
            if 'location' in info:
                self.client.set_account_uri(info['location'])
            return True, result
        elif info['status'] == (409 if self.client.version == 1 else 200):
            # Account did exist
            if result.get('status') == 'deactivated':
                # A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and
                # Boulder (https://github.com/letsencrypt/boulder/issues/3971): this should
                # not return a valid account object according to
                # https://tools.ietf.org/html/rfc8555#section-7.3.6:
                #     "Once an account is deactivated, the server MUST NOT accept further
                #      requests authorized by that account's key."
                if not allow_creation:
                    return False, None
                else:
                    raise ModuleFailException("Account is deactivated")
            if 'location' in info:
                self.client.set_account_uri(info['location'])
            return False, result
        elif info['status'] == 400 and result[
                'type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation:
            # Account does not exist (and we didn't try to create it)
            return False, None
        elif info['status'] == 403 and result[
                'type'] == 'urn:ietf:params:acme:error:unauthorized' and 'deactivated' in (
                    result.get('detail') or ''):
            # Account has been deactivated; currently works for Pebble; hasn't been
            # implemented for Boulder (https://github.com/letsencrypt/boulder/issues/3971),
            # might need adjustment in error detection.
            if not allow_creation:
                return False, None
            else:
                raise ModuleFailException("Account is deactivated")
        else:
            raise ACMEProtocolException(self.client.module,
                                        msg='Registering ACME account failed',
                                        info=info,
                                        content_json=result)
Ejemplo n.º 20
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),
            private_key_passphrase=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,
    )
    backend = create_backend(module, False)

    try:
        client = ACMEClient(module, backend)
        account = ACMEAccount(client)
        # 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 = client.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = client.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:
            passphrase = module.params['private_key_passphrase']
            # Step 1: load and parse private key
            try:
                private_key_data = client.parse_key(private_key,
                                                    private_key_content,
                                                    passphrase=passphrase)
            except KeyParsingError as e:
                raise ModuleFailException(
                    "Error while parsing private key: {msg}".format(msg=e.msg))
            # Step 2: sign revokation request with private key
            jws_header = {
                "alg": private_key_data['alg'],
                "jwk": private_key_data['jwk'],
            }
            result, info = client.send_signed_request(
                endpoint,
                payload,
                key_data=private_key_data,
                jws_header=jws_header,
                fail_on_error=False)
        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 = client.send_signed_request(endpoint,
                                                      payload,
                                                      fail_on_error=False)
        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 ACMEProtocolException('Failed to revoke certificate',
                                        info=info,
                                        content_json=result)
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)