ACME modules refactor (#187)
* Move acme.py to acme/__init__.py to prepare splitup. * Began moving generic code out. * Creating backends. * Update unit tests. * Move remaining new code out. * Use new interface. * Rewrite module init code. * Add changelog. * Add BackendException for crypto backend errors. * Improve / uniformize ACME error reporting. * Create ACMELegacyAccount for backwards compatibility. * Split up ACMEAccount into ACMEClient and ACMEAccount. * Move get_keyauthorization into module_utils.acme.challenges. * Improve error handling. * Move challenge and authorization handling code into module_utils. * Add split_identifier helper. * Move order code into module_utils. * Move ACME v2 certificate handling code to module_utils. * Fix/move ACME v1 certificate retrieval to module_utils as well. * Refactor alternate chain handling code by splitting it up into simpler functions. * Make chain matcher creation part of backend.pull/207/head
parent
8de9376a10
commit
5d32937321
|
@ -0,0 +1,5 @@
|
|||
minor_changes:
|
||||
- "acme module_utils - the ``acme`` module_utils has been split up into several Python modules (https://github.com/ansible-collections/community.crypto/pull/184)."
|
||||
- "acme_* modules - codebase refactor which should not be visible to end-users (https://github.com/ansible-collections/community.crypto/pull/184)."
|
||||
deprecated_features:
|
||||
- "acme module_utils - the ``acme`` module_utils (``ansible_collections.community.crypto.plugins.module_utils.acme``) is deprecated and will be removed in community.crypto 2.0.0. Use the new Python modules in the ``acme`` package instead (``ansible_collections.community.crypto.plugins.module_utils.acme.xxx``) (https://github.com/ansible-collections/community.crypto/pull/184)."
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,90 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.urls import fetch_url
|
||||
from ansible.module_utils.six.moves.urllib.parse import unquote
|
||||
from ansible.module_utils._text import to_native, to_text, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
get_default_argspec,
|
||||
ACMEDirectory,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
CryptographyBackend,
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import (
|
||||
handle_standard_module_arguments,
|
||||
set_crypto_backend,
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import ACMELegacyAccount as ACMEAccount
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
|
||||
read_file,
|
||||
write_file,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
process_links,
|
||||
)
|
||||
|
||||
|
||||
def openssl_get_csr_identifiers(openssl_binary, module, csr_filename, csr_content=None):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
return OpenSSLCLIBackend(module, openssl_binary=openssl_binary).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
|
||||
|
||||
|
||||
def cryptography_get_csr_identifiers(module, csr_filename, csr_content=None):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
return CryptographyBackend(module).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
|
||||
|
||||
|
||||
def cryptography_get_cert_days(module, cert_file, now=None):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
return CryptographyBackend(module).get_cert_days(cert_filename=cert_file, now=now)
|
|
@ -0,0 +1,267 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import locale
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import HAS_CURRENT_CRYPTOGRAPHY as _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
CryptographyBackend,
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
ACMEClient,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
|
||||
ACMEAccount,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
|
||||
create_key_authorization,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
|
||||
HAS_CURRENT_CRYPTOGRAPHY = _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY
|
||||
|
||||
|
||||
def set_crypto_backend(module):
|
||||
'''
|
||||
Sets which crypto backend to use (default: auto detection).
|
||||
|
||||
Does not care whether a new enough cryptoraphy is available or not. Must
|
||||
be called before any real stuff is done which might evaluate
|
||||
``HAS_CURRENT_CRYPTOGRAPHY``.
|
||||
'''
|
||||
global HAS_CURRENT_CRYPTOGRAPHY
|
||||
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
# Choose backend
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
pass
|
||||
elif backend == 'openssl':
|
||||
HAS_CURRENT_CRYPTOGRAPHY = False
|
||||
elif backend == 'cryptography':
|
||||
if not _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY:
|
||||
module.fail_json(msg=missing_required_lib('cryptography'))
|
||||
HAS_CURRENT_CRYPTOGRAPHY = True
|
||||
else:
|
||||
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend))
|
||||
# Inform about choices
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION))
|
||||
return 'cryptography'
|
||||
else:
|
||||
module.debug('Using OpenSSL binary backend')
|
||||
return 'openssl'
|
||||
|
||||
|
||||
def handle_standard_module_arguments(module, needs_acme_v2=False):
|
||||
'''
|
||||
Do standard module setup, argument handling and warning emitting.
|
||||
'''
|
||||
backend = set_crypto_backend(module)
|
||||
|
||||
if not module.params['validate_certs']:
|
||||
module.warn(
|
||||
'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.'
|
||||
)
|
||||
|
||||
if module.params['acme_version'] is None:
|
||||
module.params['acme_version'] = 1
|
||||
module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if module.params['acme_directory'] is None:
|
||||
module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory'
|
||||
module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if needs_acme_v2 and module.params['acme_version'] < 2:
|
||||
module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name))
|
||||
|
||||
# AnsibleModule() changes the locale, so change it back to C because we rely on time.strptime() when parsing certificate dates.
|
||||
module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
|
||||
locale.setlocale(locale.LC_ALL, 'C')
|
||||
|
||||
return backend
|
||||
|
||||
|
||||
def get_compatibility_backend(module):
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
return CryptographyBackend(module)
|
||||
else:
|
||||
return OpenSSLCLIBackend(module)
|
||||
|
||||
|
||||
class ACMELegacyAccount(object):
|
||||
'''
|
||||
ACME account object. Handles the authorized communication with the
|
||||
ACME server. Provides access to account bound information like
|
||||
the currently active authorizations and valid certificates
|
||||
'''
|
||||
|
||||
def __init__(self, module):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
backend = get_compatibility_backend(module)
|
||||
self.client = ACMEClient(module, backend)
|
||||
self.account = ACMEAccount(self.client)
|
||||
self.key = self.client.account_key_file
|
||||
self.key_content = self.client.account_key_content
|
||||
self.uri = self.client.account_uri
|
||||
self.key_data = self.client.account_key_data
|
||||
self.jwk = self.client.account_jwk
|
||||
self.jws_header = self.client.account_jws_header
|
||||
self.directory = self.client.directory
|
||||
|
||||
def get_keyauthorization(self, token):
|
||||
'''
|
||||
Returns the key authorization for the given token
|
||||
https://tools.ietf.org/html/rfc8555#section-8.1
|
||||
'''
|
||||
return create_key_authorization(self.client, token)
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
||||
(error, key_data).
|
||||
'''
|
||||
try:
|
||||
return None, self.client.parse_key(key_file=key_file, key_content=key_content)
|
||||
except KeyParsingError as e:
|
||||
return e.msg, None
|
||||
|
||||
def sign_request(self, protected, payload, key_data, encode_payload=True):
|
||||
return self.client.sign_request(protected, payload, key_data, encode_payload=encode_payload)
|
||||
|
||||
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True):
|
||||
'''
|
||||
Sends a JWS signed HTTP POST request to the ACME server and returns
|
||||
the response as dictionary
|
||||
https://tools.ietf.org/html/rfc8555#section-6.2
|
||||
|
||||
If payload is None, a POST-as-GET is performed.
|
||||
(https://tools.ietf.org/html/rfc8555#section-6.3)
|
||||
'''
|
||||
return self.client.send_signed_request(
|
||||
url,
|
||||
payload,
|
||||
key_data=key_data,
|
||||
jws_header=jws_header,
|
||||
parse_json_result=parse_json_result,
|
||||
encode_payload=encode_payload,
|
||||
fail_on_error=False,
|
||||
)
|
||||
|
||||
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True):
|
||||
'''
|
||||
Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback
|
||||
to GET if server replies with a status code of 405.
|
||||
'''
|
||||
return self.client.get_request(
|
||||
uri,
|
||||
parse_json_result=parse_json_result,
|
||||
headers=headers,
|
||||
get_only=get_only,
|
||||
fail_on_error=fail_on_error,
|
||||
)
|
||||
|
||||
def set_account_uri(self, uri):
|
||||
'''
|
||||
Set account URI. For ACME v2, it needs to be used to sending signed
|
||||
requests.
|
||||
'''
|
||||
self.client.set_account_uri(uri)
|
||||
self.uri = self.client.account_uri
|
||||
|
||||
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.
|
||||
'''
|
||||
return self.account.get_account_data()
|
||||
|
||||
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 ``self.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
|
||||
'''
|
||||
result = self.account.setup_account(
|
||||
contact=contact,
|
||||
agreement=agreement,
|
||||
terms_agreed=terms_agreed,
|
||||
allow_creation=allow_creation,
|
||||
remove_account_uri_if_not_exists=remove_account_uri_if_not_exists,
|
||||
external_account_binding=external_account_binding,
|
||||
)
|
||||
self.uri = self.client.account_uri
|
||||
return result
|
||||
|
||||
def update_account(self, account_data, contact=None):
|
||||
'''
|
||||
Update an account on the ACME server. Check mode is fully respected.
|
||||
|
||||
The current account data must be provided as ``account_data``.
|
||||
|
||||
Return a pair ``(updated, account_data)``, where ``updated`` is
|
||||
``True`` in case something changed (contact info updated) or
|
||||
would be changed (check mode), and ``account_data`` the updated
|
||||
account data.
|
||||
|
||||
https://tools.ietf.org/html/rfc8555#section-7.3.2
|
||||
'''
|
||||
return self.account.update_account(account_data, contact=contact)
|
|
@ -0,0 +1,251 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ACMEProtocolException,
|
||||
ModuleFailException,
|
||||
)
|
||||
|
||||
|
||||
class ACMEAccount(object):
|
||||
'''
|
||||
ACME account object. Allows to create new accounts, check for existence of accounts,
|
||||
retrieve account data.
|
||||
'''
|
||||
|
||||
def __init__(self, client):
|
||||
# Set to true to enable logging of all signed requests
|
||||
self._debug = False
|
||||
|
||||
self.client = client
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def update_account(self, account_data, contact=None):
|
||||
'''
|
||||
Update an account on the ACME server. Check mode is fully respected.
|
||||
|
||||
The current account data must be provided as ``account_data``.
|
||||
|
||||
Return a pair ``(updated, account_data)``, where ``updated`` is
|
||||
``True`` in case something changed (contact info updated) or
|
||||
would be changed (check mode), and ``account_data`` the updated
|
||||
account data.
|
||||
|
||||
https://tools.ietf.org/html/rfc8555#section-7.3.2
|
||||
'''
|
||||
# Create request
|
||||
update_request = {}
|
||||
if contact is not None and account_data.get('contact', []) != contact:
|
||||
update_request['contact'] = list(contact)
|
||||
|
||||
# No change?
|
||||
if not update_request:
|
||||
return False, dict(account_data)
|
||||
|
||||
# Apply change
|
||||
if self.client.module.check_mode:
|
||||
account_data = dict(account_data)
|
||||
account_data.update(update_request)
|
||||
else:
|
||||
if self.client.version == 1:
|
||||
update_request['resource'] = 'reg'
|
||||
account_data, dummy = self.client.send_signed_request(self.client.account_uri, update_request)
|
||||
return True, account_data
|
|
@ -0,0 +1,366 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import locale
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.urls import fetch_url
|
||||
from ansible.module_utils._text import to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
CryptographyBackend,
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ACMEProtocolException,
|
||||
NetworkException,
|
||||
ModuleFailException,
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
)
|
||||
|
||||
|
||||
def _assert_fetch_url_success(module, response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True):
|
||||
if info['status'] < 0:
|
||||
raise NetworkException(msg="Failure downloading %s, %s" % (info['url'], info['msg']))
|
||||
|
||||
if (300 <= info['status'] < 400 and not allow_redirect) or \
|
||||
(400 <= info['status'] < 500 and not allow_client_error) or \
|
||||
(info['status'] >= 500 and not allow_server_error):
|
||||
raise ACMEProtocolException(module, info=info, response=response)
|
||||
|
||||
|
||||
def _is_failed(info, expected_status_codes=None):
|
||||
if info['status'] < 200 or info['status'] >= 400:
|
||||
return True
|
||||
if expected_status_codes is not None and info['status'] not in expected_status_codes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ACMEDirectory(object):
|
||||
'''
|
||||
The ACME server directory. Gives access to the available resources,
|
||||
and allows to obtain a Replay-Nonce. The acme_directory URL
|
||||
needs to support unauthenticated GET requests; ACME endpoints
|
||||
requiring authentication are not supported.
|
||||
https://tools.ietf.org/html/rfc8555#section-7.1.1
|
||||
'''
|
||||
|
||||
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")
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.directory[key]
|
||||
|
||||
def get_nonce(self, resource=None):
|
||||
url = self.directory_root if self.version == 1 else self.directory['newNonce']
|
||||
if resource is not None:
|
||||
url = resource
|
||||
dummy, info = fetch_url(self.module, url, method='HEAD')
|
||||
if info['status'] not in (200, 204):
|
||||
raise NetworkException("Failed to get replay-nonce, got status {0}".format(info['status']))
|
||||
return info['replay-nonce']
|
||||
|
||||
|
||||
class ACMEClient(object):
|
||||
'''
|
||||
ACME client object. Handles the authorized communication with the
|
||||
ACME server.
|
||||
'''
|
||||
|
||||
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']
|
||||
|
||||
# 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)
|
||||
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)
|
||||
|
||||
def set_account_uri(self, uri):
|
||||
'''
|
||||
Set account URI. For ACME v2, it needs to be used to sending signed
|
||||
requests.
|
||||
'''
|
||||
self.account_uri = uri
|
||||
if self.version != 1:
|
||||
self.account_jws_header.pop('jwk')
|
||||
self.account_jws_header['kid'] = self.account_uri
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
|
||||
In case of an error, raises KeyParsingError.
|
||||
'''
|
||||
if key_file is None and key_content is None:
|
||||
raise AssertionError('One of key_file and key_content must be specified!')
|
||||
error, key_data = self.backend.parse_key(key_file, key_content)
|
||||
if error:
|
||||
raise KeyParsingError(error)
|
||||
return key_data
|
||||
|
||||
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)
|
||||
|
||||
def _log(self, msg, data=None):
|
||||
'''
|
||||
Write arguments to acme.log when logging is enabled.
|
||||
'''
|
||||
if self._debug:
|
||||
with open('acme.log', 'ab') as f:
|
||||
f.write('[{0}] {1}\n'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%s'), msg).encode('utf-8'))
|
||||
if data is not None:
|
||||
f.write('{0}\n\n'.format(json.dumps(data, indent=2, sort_keys=True)).encode('utf-8'))
|
||||
|
||||
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True,
|
||||
encode_payload=True, fail_on_error=True, error_msg=None, expected_status_codes=None):
|
||||
'''
|
||||
Sends a JWS signed HTTP POST request to the ACME server and returns
|
||||
the response as dictionary (if parse_json_result is True) or in raw form
|
||||
(if parse_json_result is False).
|
||||
https://tools.ietf.org/html/rfc8555#section-6.2
|
||||
|
||||
If payload is None, a POST-as-GET is performed.
|
||||
(https://tools.ietf.org/html/rfc8555#section-6.3)
|
||||
'''
|
||||
key_data = key_data or self.account_key_data
|
||||
jws_header = jws_header or self.account_jws_header
|
||||
failed_tries = 0
|
||||
while True:
|
||||
protected = copy.deepcopy(jws_header)
|
||||
protected["nonce"] = self.directory.get_nonce()
|
||||
if self.version != 1:
|
||||
protected["url"] = url
|
||||
|
||||
self._log('URL', url)
|
||||
self._log('protected', protected)
|
||||
self._log('payload', payload)
|
||||
data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload)
|
||||
if self.version == 1:
|
||||
data["header"] = jws_header.copy()
|
||||
for k, v in protected.items():
|
||||
dummy = data["header"].pop(k, None)
|
||||
self._log('signed request', data)
|
||||
data = self.module.jsonify(data)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/jose+json',
|
||||
}
|
||||
resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST')
|
||||
_assert_fetch_url_success(self.module, resp, info)
|
||||
result = {}
|
||||
try:
|
||||
content = resp.read()
|
||||
except AttributeError:
|
||||
content = info.pop('body', None)
|
||||
|
||||
if content or not parse_json_result:
|
||||
if (parse_json_result and info['content-type'].startswith('application/json')) or 400 <= info['status'] < 600:
|
||||
try:
|
||||
decoded_result = self.module.from_json(content.decode('utf8'))
|
||||
self._log('parsed result', decoded_result)
|
||||
# In case of badNonce error, try again (up to 5 times)
|
||||
# (https://tools.ietf.org/html/rfc8555#section-6.7)
|
||||
if all((
|
||||
400 <= info['status'] < 600,
|
||||
decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce',
|
||||
failed_tries <= 5,
|
||||
)):
|
||||
failed_tries += 1
|
||||
continue
|
||||
if parse_json_result:
|
||||
result = decoded_result
|
||||
else:
|
||||
result = content
|
||||
except ValueError:
|
||||
raise NetworkException("Failed to parse the ACME response: {0} {1}".format(url, content))
|
||||
else:
|
||||
result = content
|
||||
|
||||
if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes):
|
||||
raise ACMEProtocolException(
|
||||
self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None)
|
||||
return result, info
|
||||
|
||||
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False,
|
||||
fail_on_error=True, error_msg=None, expected_status_codes=None):
|
||||
'''
|
||||
Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback
|
||||
to GET if server replies with a status code of 405.
|
||||
'''
|
||||
if not get_only and self.version != 1:
|
||||
# Try POST-as-GET
|
||||
content, info = self.send_signed_request(uri, None, parse_json_result=False, fail_on_error=False)
|
||||
if info['status'] == 405:
|
||||
# Instead, do unauthenticated GET
|
||||
get_only = True
|
||||
else:
|
||||
# Do unauthenticated GET
|
||||
get_only = True
|
||||
|
||||
if get_only:
|
||||
# Perform unauthenticated GET
|
||||
resp, info = fetch_url(self.module, uri, method='GET', headers=headers)
|
||||
|
||||
_assert_fetch_url_success(self.module, resp, info)
|
||||
|
||||
try:
|
||||
content = resp.read()
|
||||
except AttributeError:
|
||||
content = info.pop('body', None)
|
||||
|
||||
# Process result
|
||||
if parse_json_result:
|
||||
result = {}
|
||||
if content:
|
||||
if info['content-type'].startswith('application/json'):
|
||||
try:
|
||||
result = self.module.from_json(content.decode('utf8'))
|
||||
except ValueError:
|
||||
raise NetworkException("Failed to parse the ACME response: {0} {1}".format(uri, content))
|
||||
else:
|
||||
result = content
|
||||
else:
|
||||
result = content
|
||||
|
||||
if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes):
|
||||
raise ACMEProtocolException(
|
||||
self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None)
|
||||
return result, info
|
||||
|
||||
|
||||
def get_default_argspec():
|
||||
'''
|
||||
Provides default argument spec for the options documented in the acme doc fragment.
|
||||
'''
|
||||
return dict(
|
||||
account_key_src=dict(type='path', aliases=['account_key']),
|
||||
account_key_content=dict(type='str', no_log=True),
|
||||
account_uri=dict(type='str'),
|
||||
acme_directory=dict(type='str'),
|
||||
acme_version=dict(type='int', choices=[1, 2]),
|
||||
validate_certs=dict(type='bool', default=True),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']),
|
||||
)
|
||||
|
||||
|
||||
def create_backend(module, needs_acme_v2):
|
||||
backend = module.params['select_crypto_backend']
|
||||
|
||||
# Backend autodetect
|
||||
if backend == 'auto':
|
||||
backend = 'cryptography' if HAS_CURRENT_CRYPTOGRAPHY else 'openssl'
|
||||
|
||||
# Create backend object
|
||||
if backend == 'cryptography':
|
||||
if not HAS_CURRENT_CRYPTOGRAPHY:
|
||||
module.fail_json(msg=missing_required_lib('cryptography'))
|
||||
module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION))
|
||||
module_backend = CryptographyBackend(module)
|
||||
elif backend == 'openssl':
|
||||
module.debug('Using OpenSSL binary backend')
|
||||
module_backend = OpenSSLCLIBackend(module)
|
||||
else:
|
||||
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend))
|
||||
|
||||
# Check common module parameters
|
||||
if not module.params['validate_certs']:
|
||||
module.warn(
|
||||
'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.'
|
||||
)
|
||||
|
||||
if module.params['acme_version'] is None:
|
||||
module.params['acme_version'] = 1
|
||||
module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if module.params['acme_directory'] is None:
|
||||
module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory'
|
||||
module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if needs_acme_v2 and module.params['acme_version'] < 2:
|
||||
module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name))
|
||||
|
||||
# AnsibleModule() changes the locale, so change it back to C because we rely
|
||||
# on datetime.datetime.strptime() when parsing certificate dates.
|
||||
locale.setlocale(locale.LC_ALL, 'C')
|
||||
|
||||
return module_backend
|
|
@ -0,0 +1,369 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ansible.module_utils._text import to_bytes, to_native
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import (
|
||||
CryptoBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
|
||||
ChainMatcher,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import BackendException
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import read_file
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
parse_name_field,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
|
||||
cryptography_name_to_oid,
|
||||
)
|
||||
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.hazmat.primitives.hashes
|
||||
import cryptography.hazmat.primitives.hmac
|
||||
import cryptography.hazmat.primitives.asymmetric.ec
|
||||
import cryptography.hazmat.primitives.asymmetric.padding
|
||||
import cryptography.hazmat.primitives.asymmetric.rsa
|
||||
import cryptography.hazmat.primitives.asymmetric.utils
|
||||
import cryptography.hazmat.primitives.serialization
|
||||
import cryptography.x509
|
||||
import cryptography.x509.oid
|
||||
from distutils.version import LooseVersion
|
||||
CRYPTOGRAPHY_VERSION = cryptography.__version__
|
||||
HAS_CURRENT_CRYPTOGRAPHY = (LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion('1.5'))
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
_cryptography_backend = cryptography.hazmat.backends.default_backend()
|
||||
except Exception as dummy:
|
||||
HAS_CURRENT_CRYPTOGRAPHY = False
|
||||
CRYPTOGRAPHY_VERSION = None
|
||||
|
||||
|
||||
if sys.version_info[0] >= 3:
|
||||
# Python 3 (and newer)
|
||||
def _count_bytes(n):
|
||||
return (n.bit_length() + 7) // 8 if n > 0 else 0
|
||||
|
||||
def _convert_int_to_bytes(count, no):
|
||||
return no.to_bytes(count, byteorder='big')
|
||||
|
||||
def _pad_hex(n, digits):
|
||||
res = hex(n)[2:]
|
||||
if len(res) < digits:
|
||||
res = '0' * (digits - len(res)) + res
|
||||
return res
|
||||
else:
|
||||
# Python 2
|
||||
def _count_bytes(n):
|
||||
if n <= 0:
|
||||
return 0
|
||||
h = '%x' % n
|
||||
return (len(h) + 1) // 2
|
||||
|
||||
def _convert_int_to_bytes(count, n):
|
||||
h = '%x' % n
|
||||
if len(h) > 2 * count:
|
||||
raise Exception('Number {1} needs more than {0} bytes!'.format(count, n))
|
||||
return ('0' * (2 * count - len(h)) + h).decode('hex')
|
||||
|
||||
def _pad_hex(n, digits):
|
||||
h = '%x' % n
|
||||
if len(h) < digits:
|
||||
h = '0' * (digits - len(h)) + h
|
||||
return h
|
||||
|
||||
|
||||
class CryptographyChainMatcher(ChainMatcher):
|
||||
@staticmethod
|
||||
def _parse_key_identifier(key_identifier, name, criterium_idx, module):
|
||||
if key_identifier:
|
||||
try:
|
||||
return binascii.unhexlify(key_identifier.replace(':', ''))
|
||||
except Exception:
|
||||
if criterium_idx is None:
|
||||
module.warn('Criterium has invalid {0} value. Ignoring criterium.'.format(name))
|
||||
else:
|
||||
module.warn('Criterium {0} in select_chain has invalid {1} value. '
|
||||
'Ignoring criterium.'.format(criterium_idx, name))
|
||||
return None
|
||||
|
||||
def __init__(self, criterium, module):
|
||||
self.criterium = criterium
|
||||
self.test_certificates = criterium.test_certificates
|
||||
self.subject = []
|
||||
self.issuer = []
|
||||
if criterium.subject:
|
||||
self.subject = [
|
||||
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.subject)
|
||||
]
|
||||
if criterium.issuer:
|
||||
self.issuer = [
|
||||
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.issuer)
|
||||
]
|
||||
self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier(
|
||||
criterium.subject_key_identifier, 'subject_key_identifier', criterium.index, module)
|
||||
self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier(
|
||||
criterium.authority_key_identifier, 'authority_key_identifier', criterium.index, module)
|
||||
|
||||
def _match_subject(self, x509_subject, match_subject):
|
||||
for oid, value in match_subject:
|
||||
found = False
|
||||
for attribute in x509_subject:
|
||||
if attribute.oid == oid and value == to_native(attribute.value):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
return True
|
||||
|
||||
def match(self, certificate):
|
||||
'''
|
||||
Check whether an alternate chain matches the specified criterium.
|
||||
'''
|
||||
chain = certificate.chain
|
||||
if self.test_certificates == 'last':
|
||||
chain = chain[-1:]
|
||||
elif self.test_certificates == 'first':
|
||||
chain = chain[:1]
|
||||
for cert in chain:
|
||||
try:
|
||||
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend())
|
||||
matches = True
|
||||
if not self._match_subject(x509.subject, self.subject):
|
||||
matches = False
|
||||
if not self._match_subject(x509.issuer, self.issuer):
|
||||
matches = False
|
||||
if self.subject_key_identifier:
|
||||
try:
|
||||
ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier)
|
||||
if self.subject_key_identifier != ext.value.digest:
|
||||
matches = False
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
matches = False
|
||||
if self.authority_key_identifier:
|
||||
try:
|
||||
ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier)
|
||||
if self.authority_key_identifier != ext.value.key_identifier:
|
||||
matches = False
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
matches = False
|
||||
if matches:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e))
|
||||
return False
|
||||
|
||||
|
||||
class CryptographyBackend(CryptoBackend):
|
||||
def __init__(self, module):
|
||||
super(CryptographyBackend, self).__init__(module)
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
||||
(error, key_data).
|
||||
'''
|
||||
# If key_content isn't given, read key_file
|
||||
if key_content is None:
|
||||
key_content = read_file(key_file)
|
||||
else:
|
||||
key_content = to_bytes(key_content)
|
||||
# Parse key
|
||||
try:
|
||||
key = cryptography.hazmat.primitives.serialization.load_pem_private_key(key_content, password=None, backend=_cryptography_backend)
|
||||
except Exception as e:
|
||||
return 'error while loading key: {0}'.format(e), None
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
pk = key.public_key().public_numbers()
|
||||
return None, {
|
||||
'key_obj': key,
|
||||
'type': 'rsa',
|
||||
'alg': 'RS256',
|
||||
'jwk': {
|
||||
"kty": "RSA",
|
||||
"e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)),
|
||||
"n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)),
|
||||
},
|
||||
'hash': 'sha256',
|
||||
}
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
pk = key.public_key().public_numbers()
|
||||
if pk.curve.name == 'secp256r1':
|
||||
bits = 256
|
||||
alg = 'ES256'
|
||||
hashalg = 'sha256'
|
||||
point_size = 32
|
||||
curve = 'P-256'
|
||||
elif pk.curve.name == 'secp384r1':
|
||||
bits = 384
|
||||
alg = 'ES384'
|
||||
hashalg = 'sha384'
|
||||
point_size = 48
|
||||
curve = 'P-384'
|
||||
elif pk.curve.name == 'secp521r1':
|
||||
# Not yet supported on Let's Encrypt side, see
|
||||
# https://github.com/letsencrypt/boulder/issues/2217
|
||||
bits = 521
|
||||
alg = 'ES512'
|
||||
hashalg = 'sha512'
|
||||
point_size = 66
|
||||
curve = 'P-521'
|
||||
else:
|
||||
return 'unknown elliptic curve: {0}'.format(pk.curve.name), {}
|
||||
num_bytes = (bits + 7) // 8
|
||||
return None, {
|
||||
'key_obj': key,
|
||||
'type': 'ec',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
"kty": "EC",
|
||||
"crv": curve,
|
||||
"x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)),
|
||||
"y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)),
|
||||
},
|
||||
'hash': hashalg,
|
||||
'point_size': point_size,
|
||||
}
|
||||
else:
|
||||
return 'unknown key type "{0}"'.format(type(key)), {}
|
||||
|
||||
def sign(self, payload64, protected64, key_data):
|
||||
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
|
||||
if 'mac_obj' in key_data:
|
||||
mac = key_data['mac_obj']()
|
||||
mac.update(sign_payload)
|
||||
signature = mac.finalize()
|
||||
elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15()
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA256
|
||||
signature = key_data['key_obj'].sign(sign_payload, padding, hashalg())
|
||||
elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
if key_data['hash'] == 'sha256':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA256
|
||||
elif key_data['hash'] == 'sha384':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA384
|
||||
elif key_data['hash'] == 'sha512':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA512
|
||||
ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg())
|
||||
r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(key_data['key_obj'].sign(sign_payload, ecdsa))
|
||||
rr = _pad_hex(r, 2 * key_data['point_size'])
|
||||
ss = _pad_hex(s, 2 * key_data['point_size'])
|
||||
signature = binascii.unhexlify(rr) + binascii.unhexlify(ss)
|
||||
|
||||
return {
|
||||
"protected": protected64,
|
||||
"payload": payload64,
|
||||
"signature": nopad_b64(signature),
|
||||
}
|
||||
|
||||
def create_mac_key(self, alg, key):
|
||||
'''Create a MAC key.'''
|
||||
if alg == 'HS256':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA256
|
||||
hashbytes = 32
|
||||
elif alg == 'HS384':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA384
|
||||
hashbytes = 48
|
||||
elif alg == 'HS512':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA512
|
||||
hashbytes = 64
|
||||
else:
|
||||
raise BackendException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg))
|
||||
key_bytes = base64.urlsafe_b64decode(key)
|
||||
if len(key_bytes) < hashbytes:
|
||||
raise BackendException(
|
||||
'{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes))
|
||||
return {
|
||||
'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC(
|
||||
key_bytes,
|
||||
hashalg(),
|
||||
_cryptography_backend),
|
||||
'type': 'hmac',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
'kty': 'oct',
|
||||
'k': key,
|
||||
},
|
||||
}
|
||||
|
||||
def get_csr_identifiers(self, csr_filename=None, 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([])
|
||||
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))
|
||||
for extension in csr.extensions:
|
||||
if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
|
||||
for name in extension.value:
|
||||
if isinstance(name, cryptography.x509.DNSName):
|
||||
identifiers.add(('dns', name.value))
|
||||
elif isinstance(name, cryptography.x509.IPAddress):
|
||||
identifiers.add(('ip', name.value.compressed))
|
||||
else:
|
||||
raise BackendException('Found unsupported SAN identifier {0}'.format(name))
|
||||
return identifiers
|
||||
|
||||
def get_cert_days(self, cert_filename=None, cert_content=None, now=None):
|
||||
'''
|
||||
Return the days the certificate in cert_filename remains valid and -1
|
||||
if the file was not found. If cert_filename contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
|
||||
If now is not specified, datetime.datetime.now() is used.
|
||||
'''
|
||||
if cert_filename is not None:
|
||||
cert_content = None
|
||||
if os.path.exists(cert_filename):
|
||||
cert_content = read_file(cert_filename)
|
||||
else:
|
||||
cert_content = to_bytes(cert_content)
|
||||
|
||||
if cert_content is None:
|
||||
return -1
|
||||
|
||||
try:
|
||||
cert = cryptography.x509.load_pem_x509_certificate(cert_content, _cryptography_backend)
|
||||
except Exception as e:
|
||||
if cert_filename is None:
|
||||
raise BackendException('Cannot parse certificate: {0}'.format(e))
|
||||
raise BackendException('Cannot parse certificate {0}: {1}'.format(cert_filename, e))
|
||||
|
||||
if now is None:
|
||||
now = datetime.datetime.now()
|
||||
return (cert.not_valid_after - now).days
|
||||
|
||||
def create_chain_matcher(self, criterium):
|
||||
'''
|
||||
Given a Criterium object, creates a ChainMatcher object.
|
||||
'''
|
||||
return CryptographyChainMatcher(criterium, self.module)
|
|
@ -0,0 +1,295 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils._text import to_native, to_text, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import (
|
||||
CryptoBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
BackendException,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
|
||||
_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
|
||||
|
||||
|
||||
class OpenSSLCLIBackend(CryptoBackend):
|
||||
def __init__(self, module, openssl_binary=None):
|
||||
super(OpenSSLCLIBackend, self).__init__(module)
|
||||
if openssl_binary is None:
|
||||
openssl_binary = module.get_bin_path('openssl', True)
|
||||
self.openssl_binary = openssl_binary
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
||||
(error, key_data).
|
||||
'''
|
||||
# If key_file isn't given, but key_content, write that to a temporary file
|
||||
if key_file is None:
|
||||
fd, tmpsrc = tempfile.mkstemp()
|
||||
self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
|
||||
f = os.fdopen(fd, 'wb')
|
||||
try:
|
||||
f.write(key_content.encode('utf-8'))
|
||||
key_file = tmpsrc
|
||||
except Exception as err:
|
||||
try:
|
||||
f.close()
|
||||
except Exception as dummy:
|
||||
pass
|
||||
raise BackendException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
|
||||
f.close()
|
||||
# Parse key
|
||||
account_key_type = None
|
||||
with open(key_file, "rt") as f:
|
||||
for line in f:
|
||||
m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line)
|
||||
if m is not None:
|
||||
account_key_type = m.group(1).lower()
|
||||
break
|
||||
if account_key_type is None:
|
||||
# This happens for example if openssl_privatekey created this key
|
||||
# (as opposed to the OpenSSL binary). For now, we assume this is
|
||||
# an RSA key.
|
||||
# FIXME: add some kind of auto-detection
|
||||
account_key_type = "rsa"
|
||||
if account_key_type not in ("rsa", "ec"):
|
||||
return 'unknown key type "%s"' % account_key_type, {}
|
||||
|
||||
openssl_keydump_cmd = [self.openssl_binary, account_key_type, "-in", key_file, "-noout", "-text"]
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
|
||||
if account_key_type == 'rsa':
|
||||
pub_hex, pub_exp = re.search(
|
||||
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
|
||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups()
|
||||
pub_exp = "{0:x}".format(int(pub_exp))
|
||||
if len(pub_exp) % 2:
|
||||
pub_exp = "0{0}".format(pub_exp)
|
||||
|
||||
return None, {
|
||||
'key_file': key_file,
|
||||
'type': 'rsa',
|
||||
'alg': 'RS256',
|
||||
'jwk': {
|
||||
"kty": "RSA",
|
||||
"e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
|
||||
"n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
|
||||
},
|
||||
'hash': 'sha256',
|
||||
}
|
||||
elif account_key_type == 'ec':
|
||||
pub_data = re.search(
|
||||
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
|
||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
|
||||
if pub_data is None:
|
||||
return 'cannot parse elliptic curve key', {}
|
||||
pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8"))
|
||||
asn1_oid_curve = pub_data.group(2).lower()
|
||||
nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None
|
||||
if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256':
|
||||
bits = 256
|
||||
alg = 'ES256'
|
||||
hashalg = 'sha256'
|
||||
point_size = 32
|
||||
curve = 'P-256'
|
||||
elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384':
|
||||
bits = 384
|
||||
alg = 'ES384'
|
||||
hashalg = 'sha384'
|
||||
point_size = 48
|
||||
curve = 'P-384'
|
||||
elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521':
|
||||
# Not yet supported on Let's Encrypt side, see
|
||||
# https://github.com/letsencrypt/boulder/issues/2217
|
||||
bits = 521
|
||||
alg = 'ES512'
|
||||
hashalg = 'sha512'
|
||||
point_size = 66
|
||||
curve = 'P-521'
|
||||
else:
|
||||
return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {}
|
||||
num_bytes = (bits + 7) // 8
|
||||
if len(pub_hex) != 2 * num_bytes:
|
||||
return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {}
|
||||
return None, {
|
||||
'key_file': key_file,
|
||||
'type': 'ec',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
"kty": "EC",
|
||||
"crv": curve,
|
||||
"x": nopad_b64(pub_hex[:num_bytes]),
|
||||
"y": nopad_b64(pub_hex[num_bytes:]),
|
||||
},
|
||||
'hash': hashalg,
|
||||
'point_size': point_size,
|
||||
}
|
||||
|
||||
def sign(self, payload64, protected64, key_data):
|
||||
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
|
||||
if key_data['type'] == 'hmac':
|
||||
hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k'])))
|
||||
cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"]
|
||||
else:
|
||||
cmd_postfix = ["-sign", key_data['key_file']]
|
||||
openssl_sign_cmd = [self.openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix
|
||||
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
|
||||
if key_data['type'] == 'ec':
|
||||
dummy, der_out, dummy = self.module.run_command(
|
||||
[self.openssl_binary, "asn1parse", "-inform", "DER"],
|
||||
data=out, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
expected_len = 2 * key_data['point_size']
|
||||
sig = re.findall(
|
||||
r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len,
|
||||
to_text(der_out, errors='surrogate_or_strict'))
|
||||
if len(sig) != 2:
|
||||
raise BackendException(
|
||||
"failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
|
||||
to_text(der_out, errors='surrogate_or_strict')))
|
||||
sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
|
||||
sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
|
||||
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
|
||||
|
||||
return {
|
||||
"protected": protected64,
|
||||
"payload": payload64,
|
||||
"signature": nopad_b64(to_bytes(out)),
|
||||
}
|
||||
|
||||
def create_mac_key(self, alg, key):
|
||||
'''Create a MAC key.'''
|
||||
if alg == 'HS256':
|
||||
hashalg = 'sha256'
|
||||
hashbytes = 32
|
||||
elif alg == 'HS384':
|
||||
hashalg = 'sha384'
|
||||
hashbytes = 48
|
||||
elif alg == 'HS512':
|
||||
hashalg = 'sha512'
|
||||
hashbytes = 64
|
||||
else:
|
||||
raise BackendException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg))
|
||||
key_bytes = base64.urlsafe_b64decode(key)
|
||||
if len(key_bytes) < hashbytes:
|
||||
raise BackendException(
|
||||
'{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes))
|
||||
return {
|
||||
'type': 'hmac',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
'kty': 'oct',
|
||||
'k': key,
|
||||
},
|
||||
'hash': hashalg,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ip(ip):
|
||||
try:
|
||||
return to_native(compat_ipaddress.ip_address(to_text(ip)).compressed)
|
||||
except ValueError:
|
||||
# We don't want to error out on something IPAddress() can't parse
|
||||
return ip
|
||||
|
||||
def get_csr_identifiers(self, csr_filename=None, 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'.
|
||||
'''
|
||||
filename = csr_filename
|
||||
data = None
|
||||
if csr_content is not None:
|
||||
filename = '-'
|
||||
data = csr_content.encode('utf-8')
|
||||
|
||||
openssl_csr_cmd = [self.openssl_binary, "req", "-in", filename, "-noout", "-text"]
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_csr_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
|
||||
identifiers = set([])
|
||||
common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict'))
|
||||
if common_name is not None:
|
||||
identifiers.add(('dns', common_name.group(1)))
|
||||
subject_alt_names = re.search(
|
||||
r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n",
|
||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
|
||||
if subject_alt_names is not None:
|
||||
for san in subject_alt_names.group(1).split(", "):
|
||||
if san.lower().startswith("dns:"):
|
||||
identifiers.add(('dns', san[4:]))
|
||||
elif san.lower().startswith("ip:"):
|
||||
identifiers.add(('ip', self._normalize_ip(san[3:])))
|
||||
elif san.lower().startswith("ip address:"):
|
||||
identifiers.add(('ip', self._normalize_ip(san[11:])))
|
||||
else:
|
||||
raise BackendException('Found unsupported SAN identifier "{0}"'.format(san))
|
||||
return identifiers
|
||||
|
||||
def get_cert_days(self, cert_filename=None, cert_content=None, now=None):
|
||||
'''
|
||||
Return the days the certificate in cert_filename remains valid and -1
|
||||
if the file was not found. If cert_filename contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
|
||||
If now is not specified, datetime.datetime.now() is used.
|
||||
'''
|
||||
filename = cert_filename
|
||||
data = None
|
||||
if cert_content is not None:
|
||||
filename = '-'
|
||||
data = cert_content.encode('utf-8')
|
||||
cert_filename_suffix = ''
|
||||
elif cert_filename is not None:
|
||||
if not os.path.exists(cert_filename):
|
||||
return -1
|
||||
cert_filename_suffix = ' in {0}'.format(cert_filename)
|
||||
else:
|
||||
return -1
|
||||
|
||||
openssl_cert_cmd = [self.openssl_binary, "x509", "-in", filename, "-noout", "-text"]
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_cert_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
try:
|
||||
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", to_text(out, errors='surrogate_or_strict')).group(1)
|
||||
not_after = datetime.datetime.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')
|
||||
except AttributeError:
|
||||
raise BackendException("No 'Not after' date found{0}".format(cert_filename_suffix))
|
||||
except ValueError:
|
||||
raise BackendException("Failed to parse 'Not after' date{0}".format(cert_filename_suffix))
|
||||
if now is None:
|
||||
now = datetime.datetime.now()
|
||||
return (not_after - now).days
|
||||
|
||||
def create_chain_matcher(self, criterium):
|
||||
'''
|
||||
Given a Criterium object, creates a ChainMatcher object.
|
||||
'''
|
||||
raise BackendException('Alternate chain matching can only be used with the "cryptography" backend.')
|
|
@ -0,0 +1,58 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import abc
|
||||
|
||||
from ansible.module_utils import six
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class CryptoBackend(object):
|
||||
def __init__(self, module):
|
||||
self.module = module
|
||||
|
||||
@abc.abstractmethod
|
||||
def parse_key(self, key_file=None, key_content=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
||||
(error, key_data).
|
||||
'''
|
||||
|
||||
@abc.abstractmethod
|
||||
def sign(self, payload64, protected64, key_data):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_mac_key(self, alg, key):
|
||||
'''Create a MAC key.'''
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_csr_identifiers(self, csr_filename=None, 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'.
|
||||
'''
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_cert_days(self, cert_filename=None, cert_content=None, now=None):
|
||||
'''
|
||||
Return the days the certificate in cert_filename remains valid and -1
|
||||
if the file was not found. If cert_filename contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
|
||||
If now is not specified, datetime.datetime.now() is used.
|
||||
'''
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_chain_matcher(self, criterium):
|
||||
'''
|
||||
Given a Criterium object, creates a ChainMatcher object.
|
||||
'''
|
|
@ -0,0 +1,128 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import abc
|
||||
|
||||
from ansible.module_utils import six
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ModuleFailException,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
der_to_pem,
|
||||
nopad_b64,
|
||||
process_links,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
|
||||
split_pem_list,
|
||||
)
|
||||
|
||||
|
||||
class CertificateChain(object):
|
||||
'''
|
||||
Download and parse the certificate chain.
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4.2
|
||||
'''
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
self.cert = None
|
||||
self.chain = []
|
||||
self.alternates = []
|
||||
|
||||
@classmethod
|
||||
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
|
||||
|
||||
def _process_links(self, client, link, relation):
|
||||
if relation == 'up':
|
||||
# Process link-up headers if there was no chain in reply
|
||||
if not self.chain:
|
||||
chain_result, chain_info = client.get_request(link, parse_json_result=False)
|
||||
if chain_info['status'] in [200, 201]:
|
||||
self.chain.append(der_to_pem(chain_result))
|
||||
elif relation == 'alternate':
|
||||
self.alternates.append(link)
|
||||
|
||||
def to_json(self):
|
||||
cert = self.cert.encode('utf8')
|
||||
chain = ('\n'.join(self.chain)).encode('utf8')
|
||||
return {
|
||||
'cert': cert,
|
||||
'chain': chain,
|
||||
'full_chain': cert + chain,
|
||||
}
|
||||
|
||||
|
||||
class Criterium(object):
|
||||
def __init__(self, criterium, index=None):
|
||||
self.index = index
|
||||
self.test_certificates = criterium['test_certificates']
|
||||
self.subject = criterium['subject']
|
||||
self.issuer = criterium['issuer']
|
||||
self.subject_key_identifier = criterium['subject_key_identifier']
|
||||
self.authority_key_identifier = criterium['authority_key_identifier']
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class ChainMatcher(object):
|
||||
@abc.abstractmethod
|
||||
def match(self, certificate):
|
||||
'''
|
||||
Check whether a certificate chain (CertificateChain instance) matches.
|
||||
'''
|
||||
|
||||
|
||||
def retrieve_acme_v1_certificate(client, csr_der):
|
||||
'''
|
||||
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
|
||||
'''
|
||||
new_cert = {
|
||||
"resource": "new-cert",
|
||||
"csr": nopad_b64(csr_der),
|
||||
}
|
||||
result, info = client.send_signed_request(
|
||||
client.directory['new-cert'], new_cert, error_msg='Failed to receive certificate', expected_status_codes=[200, 201])
|
||||
cert = CertificateChain(info['location'])
|
||||
cert.cert = der_to_pem(result)
|
||||
|
||||
def f(link, relation):
|
||||
if relation == 'up':
|
||||
chain_result, chain_info = client.get_request(link, parse_json_result=False)
|
||||
if chain_info['status'] in [200, 201]:
|
||||
del cert.chain[:]
|
||||
cert.chain.append(der_to_pem(chain_result))
|
||||
|
||||
process_links(info, f)
|
||||
return cert
|
|
@ -0,0 +1,296 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from ansible.module_utils._text import to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
format_error_problem,
|
||||
ACMEProtocolException,
|
||||
ModuleFailException,
|
||||
)
|
||||
|
||||
|
||||
def create_key_authorization(client, token):
|
||||
'''
|
||||
Returns the key authorization for the given token
|
||||
https://tools.ietf.org/html/rfc8555#section-8.1
|
||||
'''
|
||||
accountkey_json = json.dumps(client.account_jwk, sort_keys=True, separators=(',', ':'))
|
||||
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
|
||||
return "{0}.{1}".format(token, thumbprint)
|
||||
|
||||
|
||||
def combine_identifier(identifier_type, identifier):
|
||||
return '{type}:{identifier}'.format(type=identifier_type, identifier=identifier)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Challenge(object):
|
||||
def __init__(self, data, url):
|
||||
self.data = data
|
||||
|
||||
self.type = data['type']
|
||||
self.url = url
|
||||
self.status = data['status']
|
||||
self.token = data.get('token')
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, client, data, url=None):
|
||||
return cls(data, url or (data['uri'] if client.version == 1 else data['url']))
|
||||
|
||||
def call_validate(self, client):
|
||||
challenge_response = {}
|
||||
if client.version == 1:
|
||||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
|
||||
key_authorization = create_key_authorization(client, token)
|
||||
challenge_response['resource'] = 'challenge'
|
||||
challenge_response['keyAuthorization'] = key_authorization
|
||||
challenge_response['type'] = self.type
|
||||
client.send_signed_request(
|
||||
self.url,
|
||||
challenge_response,
|
||||
error_msg='Failed to validate challenge',
|
||||
expected_status_codes=[200, 202],
|
||||
)
|
||||
|
||||
def to_json(self):
|
||||
return self.data.copy()
|
||||
|
||||
def get_validation_data(self, client, identifier_type, identifier):
|
||||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
|
||||
key_authorization = create_key_authorization(client, token)
|
||||
|
||||
if self.type == 'http-01':
|
||||
# https://tools.ietf.org/html/rfc8555#section-8.3
|
||||
return {
|
||||
'resource': '.well-known/acme-challenge/{token}'.format(token=token),
|
||||
'resource_value': key_authorization,
|
||||
}
|
||||
|
||||
if self.type == 'dns-01':
|
||||
if identifier_type != 'dns':
|
||||
return None
|
||||
# https://tools.ietf.org/html/rfc8555#section-8.4
|
||||
resource = '_acme-challenge'
|
||||
value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest())
|
||||
record = (resource + identifier[1:]) if identifier.startswith('*.') else '{0}.{1}'.format(resource, identifier)
|
||||
return {
|
||||
'resource': resource,
|
||||
'resource_value': value,
|
||||
'record': record,
|
||||
}
|
||||
|
||||
if self.type == 'tls-alpn-01':
|
||||
# https://www.rfc-editor.org/rfc/rfc8737.html#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(key_authorization)).digest())
|
||||
return {
|
||||
'resource': resource,
|
||||
'resource_original': combine_identifier(identifier_type, identifier),
|
||||
'resource_value': value,
|
||||
}
|
||||
|
||||
# Unknown challenge type: ignore
|
||||
return None
|
||||
|
||||
|
||||
class Authorization(object):
|
||||
def _setup(self, client, data):
|
||||
data['uri'] = self.url
|
||||
self.data = data
|
||||
self.challenges = [Challenge.from_json(client, challenge) for challenge in data['challenges']]
|
||||
if client.version == 1 and 'status' not in data:
|
||||
# https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2
|
||||
# "status (required, string): ...
|
||||
# If this field is missing, then the default value is "pending"."
|
||||
self.status = 'pending'
|
||||
else:
|
||||
self.status = data['status']
|
||||
self.identifier = data['identifier']['value']
|
||||
self.identifier_type = data['identifier']['type']
|
||||
if data.get('wildcard', False):
|
||||
self.identifier = '*.{0}'.format(self.identifier)
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
self.data = None
|
||||
self.challenges = []
|
||||
self.status = None
|
||||
self.identifier_type = None
|
||||
self.identifier = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, client, data, url):
|
||||
result = cls(url)
|
||||
result._setup(client, data)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, client, url):
|
||||
result = cls(url)
|
||||
result.refresh(client)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def create(cls, client, identifier_type, identifier):
|
||||
'''
|
||||
Create a new authorization for the given identifier.
|
||||
Return the authorization object of the new authorization
|
||||
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
|
||||
'''
|
||||
new_authz = {
|
||||
"identifier": {
|
||||
"type": identifier_type,
|
||||
"value": identifier,
|
||||
},
|
||||
}
|
||||
if client.version == 1:
|
||||
url = client.directory['new-authz']
|
||||
new_authz["resource"] = "new-authz"
|
||||
else:
|
||||
if 'newAuthz' not in client.directory.directory:
|
||||
raise ACMEProtocolException('ACME endpoint does not support pre-authorization')
|
||||
url = client.directory['newAuthz']
|
||||
|
||||
result, info = client.send_signed_request(
|
||||
url, new_authz, error_msg='Failed to request challenges', expected_status_codes=[200, 201])
|
||||
return cls.from_json(client, result, info['location'])
|
||||
|
||||
@property
|
||||
def combined_identifier(self):
|
||||
return combine_identifier(self.identifier_type, self.identifier)
|
||||
|
||||
def to_json(self):
|
||||
return self.data.copy()
|
||||
|
||||
def refresh(self, client):
|
||||
result, dummy = client.get_request(self.url)
|
||||
changed = self.data != result
|
||||
self._setup(client, result)
|
||||
return changed
|
||||
|
||||
def get_challenge_data(self, client):
|
||||
'''
|
||||
Returns a dict with the data for all proposed (and supported) challenges
|
||||
of the given authorization.
|
||||
'''
|
||||
data = {}
|
||||
for challenge in self.challenges:
|
||||
validation_data = challenge.get_validation_data(client, self.identifier_type, self.identifier)
|
||||
if validation_data is not None:
|
||||
data[challenge.type] = validation_data
|
||||
return data
|
||||
|
||||
def raise_error(self, error_msg):
|
||||
'''
|
||||
Aborts with a specific error for a challenge.
|
||||
'''
|
||||
error_details = []
|
||||
# multiple challenges could have failed at this point, gather error
|
||||
# details for all of them before failing
|
||||
for challenge in self.challenges:
|
||||
if challenge.status == 'invalid':
|
||||
msg = 'Challenge {type}'.format(type=challenge.type)
|
||||
if 'error' in challenge.data:
|
||||
msg = '{msg}: {problem}'.format(
|
||||
msg=msg,
|
||||
problem=format_error_problem(challenge.data['error'], subproblem_prefix='{0}.'.format(type)),
|
||||
)
|
||||
error_details.append(msg)
|
||||
raise ACMEProtocolException(
|
||||
'Failed to validate challenge for {identifier}: {error}. {details}'.format(
|
||||
identifier=self.combined_identifier,
|
||||
error=error_msg,
|
||||
details='; '.join(error_details),
|
||||
),
|
||||
identifier=self.combined_identifier,
|
||||
authorization=self.data,
|
||||
)
|
||||
|
||||
def find_challenge(self, challenge_type):
|
||||
for challenge in self.challenges:
|
||||
if challenge_type == challenge.type:
|
||||
return challenge
|
||||
return None
|
||||
|
||||
def wait_for_validation(self, client, callenge_type):
|
||||
while True:
|
||||
self.refresh(client)
|
||||
if self.status in ['valid', 'invalid', 'revoked']:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if self.status == 'invalid':
|
||||
self.raise_error('Status is "invalid"')
|
||||
|
||||
return self.status == 'valid'
|
||||
|
||||
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)
|
||||
|
||||
def deactivate(self, client):
|
||||
'''
|
||||
Deactivates this authorization.
|
||||
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
|
||||
https://tools.ietf.org/html/rfc8555#section-7.5.2
|
||||
'''
|
||||
if self.status != 'valid':
|
||||
return
|
||||
authz_deactivate = {
|
||||
'status': 'deactivated'
|
||||
}
|
||||
if client.version == 1:
|
||||
authz_deactivate['resource'] = 'authz'
|
||||
result, info = client.send_signed_request(self.url, authz_deactivate, fail_on_error=False)
|
||||
if 200 <= info['status'] < 300 and result.get('status') == 'deactivated':
|
||||
self.status = 'deactivated'
|
||||
return True
|
||||
return False
|
|
@ -0,0 +1,117 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
def format_error_problem(problem, subproblem_prefix=''):
|
||||
if 'title' in problem:
|
||||
msg = 'Error "{title}" ({type})'.format(
|
||||
type=problem['type'],
|
||||
title=problem['title'],
|
||||
)
|
||||
else:
|
||||
msg = 'Error {type}'.format(type=problem['type'])
|
||||
if 'detail' in problem:
|
||||
msg += ': "{detail}"'.format(detail=problem['detail'])
|
||||
subproblems = problem.get('subproblems')
|
||||
if subproblems is not None:
|
||||
msg = '{msg} Subproblems:'.format(msg=msg)
|
||||
for index, problem in enumerate(subproblems):
|
||||
index_str = '{prefix}{index}'.format(prefix=subproblem_prefix, index=index)
|
||||
msg = '{msg}\n({index}) {problem}.'.format(
|
||||
msg=msg,
|
||||
index=index_str,
|
||||
problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index_str)),
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
class ModuleFailException(Exception):
|
||||
'''
|
||||
If raised, module.fail_json() will be called with the given parameters after cleanup.
|
||||
'''
|
||||
def __init__(self, msg, **args):
|
||||
super(ModuleFailException, self).__init__(self, msg)
|
||||
self.msg = msg
|
||||
self.module_fail_args = args
|
||||
|
||||
def do_fail(self, module, **arguments):
|
||||
module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments)
|
||||
|
||||
|
||||
class ACMEProtocolException(ModuleFailException):
|
||||
def __init__(self, module, msg=None, info=None, response=None, content=None, content_json=None):
|
||||
# Try to get hold of content, if response is given and content is not provided
|
||||
if content is None and content_json is None and response is not None:
|
||||
try:
|
||||
content = response.read()
|
||||
except AttributeError:
|
||||
content = info.pop('body', None)
|
||||
|
||||
# Try to get hold of JSON decoded content, when content is given and JSON not provided
|
||||
if content_json is None and content is not None:
|
||||
try:
|
||||
content_json = module.from_json(content.decode('utf8'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
extras = dict()
|
||||
url = info['url'] if info else None
|
||||
code = info['status'] if info else None
|
||||
extras['http_url'] = url
|
||||
extras['http_status'] = code
|
||||
|
||||
if msg is None:
|
||||
msg = 'ACME request failed'
|
||||
add_msg = ''
|
||||
|
||||
if code >= 400 and content_json is not None and 'type' in content_json:
|
||||
if 'status' in content_json and content_json['status'] != code:
|
||||
code = 'status {problem_code} (HTTP status: {http_code})'.format(http_code=code, problem_code=content_json['status'])
|
||||
else:
|
||||
code = 'status {problem_code}'.format(problem_code=code)
|
||||
add_msg = ' {problem}.'.format(problem=format_error_problem(content_json))
|
||||
|
||||
subproblems = content_json.pop('subproblems', None)
|
||||
extras['problem'] = content_json
|
||||
extras['subproblems'] = subproblems or []
|
||||
if subproblems is not None:
|
||||
add_msg = '{add_msg} Subproblems:'.format(add_msg=add_msg)
|
||||
for index, problem in enumerate(subproblems):
|
||||
add_msg = '{add_msg}\n({index}) {problem}.'.format(
|
||||
add_msg=add_msg,
|
||||
index=index,
|
||||
problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index)),
|
||||
)
|
||||
else:
|
||||
code = 'HTTP status {code}'.format(code=code)
|
||||
if content_json is not None:
|
||||
add_msg = ' The JSON error result: {content}'.format(content=content_json)
|
||||
elif content is not None:
|
||||
add_msg = ' The raw error result: {content}'.format(content=content.decode('utf-8'))
|
||||
|
||||
super(ACMEProtocolException, self).__init__(
|
||||
'{msg} for {url} with {code}.{add_msg}'.format(msg=msg, url=url, code=code, add_msg=add_msg),
|
||||
**extras
|
||||
)
|
||||
self.problem = {}
|
||||
self.subproblems = []
|
||||
for k, v in extras.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
class BackendException(ModuleFailException):
|
||||
pass
|
||||
|
||||
|
||||
class NetworkException(ModuleFailException):
|
||||
pass
|
||||
|
||||
|
||||
class KeyParsingError(ModuleFailException):
|
||||
pass
|
|
@ -0,0 +1,86 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2013, Romeo Theriault <romeot () hawaii.edu>
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils._text import to_native
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
# This function was adapted from an earlier version of https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/uri.py
|
||||
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
|
|
@ -0,0 +1,125 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import time
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ACMEProtocolException,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
|
||||
Authorization,
|
||||
)
|
||||
|
||||
|
||||
class Order(object):
|
||||
def _setup(self, client, data):
|
||||
self.data = data
|
||||
|
||||
self.status = data['status']
|
||||
self.identifiers = []
|
||||
for identifier in data['identifiers']:
|
||||
self.identifiers.append((identifier['type'], identifier['value']))
|
||||
self.finalize_uri = data.get('finalize')
|
||||
self.certificate_uri = data.get('certificate')
|
||||
self.authorization_uris = data['authorizations']
|
||||
self.authorizations = {}
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
self.data = None
|
||||
|
||||
self.status = None
|
||||
self.identifiers = []
|
||||
self.finalize_uri = None
|
||||
self.certificate_uri = None
|
||||
self.authorization_uris = []
|
||||
self.authorizations = {}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, client, data, url):
|
||||
result = cls(url)
|
||||
result._setup(client, data)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, client, url):
|
||||
result = cls(url)
|
||||
result.refresh(client)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def create(cls, client, identifiers):
|
||||
'''
|
||||
Start a new certificate order (ACME v2 protocol).
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4
|
||||
'''
|
||||
acme_identifiers = []
|
||||
for identifier_type, identifier in identifiers:
|
||||
acme_identifiers.append({
|
||||
'type': identifier_type,
|
||||
'value': identifier,
|
||||
})
|
||||
new_order = {
|
||||
"identifiers": acme_identifiers
|
||||
}
|
||||
result, info = client.send_signed_request(
|
||||
client.directory['newOrder'], new_order, error_msg='Failed to start new order', expected_status_codes=[201])
|
||||
return cls.from_json(client, result, info['location'])
|
||||
|
||||
def refresh(self, client):
|
||||
result, dummy = client.get_request(self.url)
|
||||
changed = self.data != result
|
||||
self._setup(client, result)
|
||||
return changed
|
||||
|
||||
def load_authorizations(self, client):
|
||||
for auth_uri in self.authorization_uris:
|
||||
authz = Authorization.from_url(client, auth_uri)
|
||||
self.authorizations[authz.combined_identifier] = authz
|
||||
|
||||
def wait_for_finalization(self, client):
|
||||
while True:
|
||||
self.refresh(client)
|
||||
if self.status in ['valid', 'invalid', 'pending', 'ready']:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if self.status != 'valid':
|
||||
raise ACMEProtocolException(
|
||||
'Failed to wait for order to complete; got status "{status}"'.format(status=self.status), content_json=self.data)
|
||||
|
||||
def finalize(self, client, csr_der, wait=True):
|
||||
'''
|
||||
Create a new certificate based on the csr.
|
||||
Return the certificate object as dict
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4
|
||||
'''
|
||||
new_cert = {
|
||||
"csr": nopad_b64(csr_der),
|
||||
}
|
||||
result, info = client.send_signed_request(
|
||||
self.finalize_uri, new_cert, error_msg='Failed to finalizing order', expected_status_codes=[200])
|
||||
# It is not clear from the RFC whether the finalize call returns the order object or not.
|
||||
# Instead of using the result, we call self.refresh(client) below.
|
||||
|
||||
if wait:
|
||||
self.wait_for_finalization(client)
|
||||
else:
|
||||
self.refresh(client)
|
||||
if self.status not in ['procesing', 'valid', 'invalid']:
|
||||
raise ACMEProtocolException(
|
||||
'Failed to finalize order; got status "{status}"'.format(
|
||||
status=self.status), info=info, content_json=result)
|
|
@ -0,0 +1,71 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# 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
|
||||
|
||||
|
||||
import base64
|
||||
import re
|
||||
import textwrap
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible.module_utils.six.moves.urllib.parse import unquote
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
|
||||
|
||||
|
||||
def nopad_b64(data):
|
||||
return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "")
|
||||
|
||||
|
||||
def der_to_pem(der_cert):
|
||||
'''
|
||||
Convert the DER format certificate in der_cert to a PEM format certificate and return it.
|
||||
'''
|
||||
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
|
||||
"\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64)))
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def process_links(info, callback):
|
||||
'''
|
||||
Process link header, calls callback for every link header with the URL and relation as options.
|
||||
'''
|
||||
if 'link' in info:
|
||||
link = info['link']
|
||||
for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link):
|
||||
callback(unquote(url), relation)
|
|
@ -158,11 +158,19 @@ import base64
|
|||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
handle_standard_module_arguments,
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
create_backend,
|
||||
get_default_argspec,
|
||||
ACMEClient,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
|
||||
ACMEAccount,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ModuleFailException,
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
|
||||
|
@ -197,7 +205,7 @@ def main():
|
|||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
handle_standard_module_arguments(module, needs_acme_v2=True)
|
||||
backend = create_backend(module, True)
|
||||
|
||||
if module.params['external_account_binding']:
|
||||
# Make sure padding is there
|
||||
|
@ -212,7 +220,8 @@ def main():
|
|||
module.params['external_account_binding']['key'] = key
|
||||
|
||||
try:
|
||||
account = ACMEAccount(module)
|
||||
client = ACMEClient(module, backend)
|
||||
account = ACMEAccount(client)
|
||||
changed = False
|
||||
state = module.params.get('state')
|
||||
diff_before = {}
|
||||
|
@ -221,7 +230,7 @@ def main():
|
|||
created, account_data = account.setup_account(allow_creation=False)
|
||||
if account_data:
|
||||
diff_before = dict(account_data)
|
||||
diff_before['public_account_key'] = account.key_data['jwk']
|
||||
diff_before['public_account_key'] = client.account_key_data['jwk']
|
||||
if created:
|
||||
raise AssertionError('Unwanted account creation')
|
||||
if account_data is not None:
|
||||
|
@ -231,9 +240,8 @@ def main():
|
|||
payload = {
|
||||
'status': 'deactivated'
|
||||
}
|
||||
result, info = account.send_signed_request(account.uri, payload)
|
||||
if info['status'] != 200:
|
||||
raise ModuleFailException('Error deactivating account: {0} {1}'.format(info['status'], result))
|
||||
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')
|
||||
|
@ -252,21 +260,22 @@ def main():
|
|||
diff_before = {}
|
||||
else:
|
||||
diff_before = dict(account_data)
|
||||
diff_before['public_account_key'] = account.key_data['jwk']
|
||||
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'] = account.key_data['jwk']
|
||||
diff_after['public_account_key'] = client.account_key_data['jwk']
|
||||
elif state == 'changed_key':
|
||||
# Parse new account key
|
||||
error, new_key_data = account.parse_key(
|
||||
module.params.get('new_account_key_src'),
|
||||
module.params.get('new_account_key_content')
|
||||
)
|
||||
if error:
|
||||
raise ModuleFailException("error while parsing account key: %s" % error)
|
||||
try:
|
||||
new_key_data = client.parse_key(
|
||||
module.params.get('new_account_key_src'),
|
||||
module.params.get('new_account_key_content')
|
||||
)
|
||||
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:
|
||||
|
@ -274,30 +283,29 @@ def main():
|
|||
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'] = account.key_data['jwk']
|
||||
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 = account.directory['keyChange']
|
||||
url = client.directory['keyChange']
|
||||
protected = {
|
||||
"alg": new_key_data['alg'],
|
||||
"jwk": new_key_data['jwk'],
|
||||
"url": url,
|
||||
}
|
||||
payload = {
|
||||
"account": account.uri,
|
||||
"account": client.account_uri,
|
||||
"newKey": new_key_data['jwk'], # specified in draft 12 and older
|
||||
"oldKey": account.jwk, # specified in draft 13 and newer
|
||||
"oldKey": client.account_jwk, # specified in draft 13 and newer
|
||||
}
|
||||
data = account.sign_request(protected, payload, new_key_data)
|
||||
data = client.sign_request(protected, payload, new_key_data)
|
||||
# Send request and verify result
|
||||
result, info = account.send_signed_request(url, data)
|
||||
if info['status'] != 200:
|
||||
raise ModuleFailException('Error account key rollover: {0} {1}'.format(info['status'], result))
|
||||
result, info = client.send_signed_request(
|
||||
url, data, error_msg='Failed to rollover account key', expected_status_codes=[200])
|
||||
if module._diff:
|
||||
account.key_data = new_key_data
|
||||
account.jws_header['alg'] = new_key_data['alg']
|
||||
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
|
||||
|
@ -306,7 +314,7 @@ def main():
|
|||
changed = True
|
||||
result = {
|
||||
'changed': changed,
|
||||
'account_uri': account.uri,
|
||||
'account_uri': client.account_uri,
|
||||
}
|
||||
if module._diff:
|
||||
result['diff'] = {
|
||||
|
|
|
@ -213,23 +213,31 @@ order_uris:
|
|||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
handle_standard_module_arguments,
|
||||
process_links,
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
create_backend,
|
||||
get_default_argspec,
|
||||
ACMEClient,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
|
||||
ACMEAccount,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
process_links,
|
||||
)
|
||||
|
||||
|
||||
def get_orders_list(module, account, orders_url):
|
||||
def get_orders_list(module, client, orders_url):
|
||||
'''
|
||||
Retrieves orders list (handles pagination).
|
||||
'''
|
||||
orders = []
|
||||
while orders_url:
|
||||
# Get part of orders list
|
||||
res, info = account.get_request(orders_url, parse_json_result=True, fail_on_error=True)
|
||||
res, info = client.get_request(orders_url, parse_json_result=True, fail_on_error=True)
|
||||
if not res.get('orders'):
|
||||
if orders:
|
||||
module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url))
|
||||
|
@ -252,11 +260,11 @@ def get_orders_list(module, account, orders_url):
|
|||
return orders
|
||||
|
||||
|
||||
def get_order(account, order_url):
|
||||
def get_order(client, order_url):
|
||||
'''
|
||||
Retrieve order data.
|
||||
'''
|
||||
return account.get_request(order_url, parse_json_result=True, fail_on_error=True)[0]
|
||||
return client.get_request(order_url, parse_json_result=True, fail_on_error=True)[0]
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -277,10 +285,11 @@ def main():
|
|||
if module._name in ('acme_account_facts', 'community.crypto.acme_account_facts'):
|
||||
module.deprecate("The 'acme_account_facts' module has been renamed to 'acme_account_info'",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
handle_standard_module_arguments(module, needs_acme_v2=True)
|
||||
backend = create_backend(module, True)
|
||||
|
||||
try:
|
||||
account = ACMEAccount(module)
|
||||
client = ACMEClient(module, backend)
|
||||
account = ACMEAccount(client)
|
||||
# Check whether account exists
|
||||
created, account_data = account.setup_account(
|
||||
[],
|
||||
|
@ -291,18 +300,18 @@ def main():
|
|||
raise AssertionError('Unwanted account creation')
|
||||
result = {
|
||||
'changed': False,
|
||||
'exists': account.uri is not None,
|
||||
'account_uri': account.uri,
|
||||
'exists': client.account_uri is not None,
|
||||
'account_uri': client.account_uri,
|
||||
}
|
||||
if account.uri is not None:
|
||||
if client.account_uri is not None:
|
||||
# Make sure promised data is there
|
||||
if 'contact' not in account_data:
|
||||
account_data['contact'] = []
|
||||
account_data['public_account_key'] = account.key_data['jwk']
|
||||
account_data['public_account_key'] = client.account_key_data['jwk']
|
||||
result['account'] = account_data
|
||||
# Retrieve orders list
|
||||
if account_data.get('orders') and module.params['retrieve_orders'] != 'ignore':
|
||||
orders = get_orders_list(module, account, account_data['orders'])
|
||||
orders = get_orders_list(module, client, account_data['orders'])
|
||||
result['order_uris'] = orders
|
||||
if module.params['retrieve_orders'] == 'url_list':
|
||||
module.deprecate(
|
||||
|
@ -312,7 +321,7 @@ def main():
|
|||
version='2.0.0', collection_name='community.crypto')
|
||||
result['orders'] = orders
|
||||
if module.params['retrieve_orders'] == 'object_list':
|
||||
result['orders'] = [get_order(account, order) for order in orders]
|
||||
result['orders'] = [get_order(client, order) for order in orders]
|
||||
module.exit_json(**result)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
|
|
@ -508,92 +508,57 @@ all_chains:
|
|||
returned: always
|
||||
'''
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from datetime import datetime
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_bytes, to_native
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
parse_name_field,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
|
||||
cryptography_name_to_oid,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
|
||||
split_pem_list,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
write_file,
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
ACMEAccount,
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
cryptography_get_csr_identifiers,
|
||||
openssl_get_csr_identifiers,
|
||||
cryptography_get_cert_days,
|
||||
handle_standard_module_arguments,
|
||||
process_links,
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
create_backend,
|
||||
get_default_argspec,
|
||||
ACMEClient,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.x509
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
|
||||
ACMEAccount,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
|
||||
combine_identifier,
|
||||
split_identifier,
|
||||
Authorization,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
|
||||
retrieve_acme_v1_certificate,
|
||||
CertificateChain,
|
||||
Criterium,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ModuleFailException,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
|
||||
write_file,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.orders import (
|
||||
Order,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
pem_to_der,
|
||||
)
|
||||
|
||||
|
||||
def get_cert_days(module, cert_file):
|
||||
'''
|
||||
Return the days the certificate in cert_file remains valid and -1
|
||||
if the file was not found. If cert_file contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
'''
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
return cryptography_get_cert_days(module, cert_file)
|
||||
if not os.path.exists(cert_file):
|
||||
return -1
|
||||
|
||||
openssl_bin = module.get_bin_path('openssl', True)
|
||||
openssl_cert_cmd = [openssl_bin, "x509", "-in", cert_file, "-noout", "-text"]
|
||||
dummy, out, dummy = module.run_command(openssl_cert_cmd, check_rc=True, encoding=None)
|
||||
try:
|
||||
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1)
|
||||
not_after = datetime.fromtimestamp(time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')))
|
||||
except AttributeError:
|
||||
raise ModuleFailException("No 'Not after' date found in {0}".format(cert_file))
|
||||
except ValueError:
|
||||
raise ModuleFailException("Failed to parse 'Not after' date of {0}".format(cert_file))
|
||||
now = datetime.utcnow()
|
||||
return (not_after - now).days
|
||||
|
||||
|
||||
class ACMEClient(object):
|
||||
class ACMECertificateClient(object):
|
||||
'''
|
||||
ACME client class. Uses an ACME account object and a CSR to
|
||||
start and validate ACME challenges and download the respective
|
||||
certificates.
|
||||
'''
|
||||
|
||||
def __init__(self, module):
|
||||
def __init__(self, module, backend):
|
||||
self.module = module
|
||||
self.version = module.params['acme_version']
|
||||
self.challenge = module.params['challenge']
|
||||
|
@ -602,13 +567,22 @@ class ACMEClient(object):
|
|||
self.dest = module.params.get('dest')
|
||||
self.fullchain_dest = module.params.get('fullchain_dest')
|
||||
self.chain_dest = module.params.get('chain_dest')
|
||||
self.account = ACMEAccount(module)
|
||||
self.directory = self.account.directory
|
||||
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.finalize_uri = 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']
|
||||
|
@ -639,286 +613,8 @@ class ACMEClient(object):
|
|||
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)
|
||||
|
||||
# Extract list of identifiers from CSR
|
||||
self.identifiers = self._get_csr_identifiers()
|
||||
|
||||
def _get_csr_identifiers(self):
|
||||
'''
|
||||
Parse the CSR and return the list of requested identifiers
|
||||
'''
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
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, self.csr_content)
|
||||
|
||||
def _add_or_update_auth(self, identifier_type, identifier, auth):
|
||||
'''
|
||||
Add or update the given authorization in the global authorizations list.
|
||||
Return True if the auth was updated/added and False if no change was
|
||||
necessary.
|
||||
'''
|
||||
if self.authorizations.get(identifier_type + ':' + identifier) == auth:
|
||||
return False
|
||||
self.authorizations[identifier_type + ':' + identifier] = auth
|
||||
return True
|
||||
|
||||
def _new_authz_v1(self, identifier_type, identifier):
|
||||
'''
|
||||
Create a new authorization for the given identifier.
|
||||
Return the authorization object of the new authorization
|
||||
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
|
||||
'''
|
||||
new_authz = {
|
||||
"resource": "new-authz",
|
||||
"identifier": {"type": identifier_type, "value": identifier},
|
||||
}
|
||||
|
||||
result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz)
|
||||
if info['status'] not in [200, 201]:
|
||||
raise ModuleFailException("Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
else:
|
||||
result['uri'] = info['location']
|
||||
return result
|
||||
|
||||
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://www.rfc-editor.org/rfc/rfc8737.html#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
|
||||
|
||||
def _fail_challenge(self, identifier_type, identifier, auth, error):
|
||||
'''
|
||||
Aborts with a specific error for a challenge.
|
||||
'''
|
||||
error_details = ''
|
||||
# multiple challenges could have failed at this point, gather error
|
||||
# details for all of them before failing
|
||||
for challenge in auth['challenges']:
|
||||
if challenge['status'] == 'invalid':
|
||||
error_details += ' CHALLENGE: {0}'.format(challenge['type'])
|
||||
if 'error' in challenge:
|
||||
error_details += ' DETAILS: {0};'.format(challenge['error']['detail'])
|
||||
else:
|
||||
error_details += ';'
|
||||
raise ModuleFailException("{0}: {1}".format(error.format(identifier_type + ':' + identifier), error_details))
|
||||
|
||||
def _validate_challenges(self, identifier_type, identifier, auth):
|
||||
'''
|
||||
Validate the authorization provided in the auth dict. Returns True
|
||||
when the validation was successful and False when it was not.
|
||||
'''
|
||||
found_challenge = False
|
||||
for challenge in auth['challenges']:
|
||||
if self.challenge != challenge['type']:
|
||||
continue
|
||||
|
||||
uri = challenge['uri'] if self.version == 1 else challenge['url']
|
||||
found_challenge = True
|
||||
|
||||
challenge_response = {}
|
||||
if self.version == 1:
|
||||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
|
||||
keyauthorization = self.account.get_keyauthorization(token)
|
||||
challenge_response["resource"] = "challenge"
|
||||
challenge_response["keyAuthorization"] = keyauthorization
|
||||
challenge_response["type"] = self.challenge
|
||||
result, info = self.account.send_signed_request(uri, challenge_response)
|
||||
if info['status'] not in [200, 202]:
|
||||
raise ModuleFailException("Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
|
||||
if not found_challenge:
|
||||
raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}:{2}!".format(
|
||||
self.challenge, identifier_type, identifier))
|
||||
|
||||
status = ''
|
||||
|
||||
while status not in ['valid', 'invalid', 'revoked']:
|
||||
result, dummy = self.account.get_request(auth['uri'])
|
||||
result['uri'] = auth['uri']
|
||||
if self._add_or_update_auth(identifier_type, identifier, result):
|
||||
self.changed = True
|
||||
# https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2
|
||||
# "status (required, string): ...
|
||||
# If this field is missing, then the default value is "pending"."
|
||||
if self.version == 1 and 'status' not in result:
|
||||
status = 'pending'
|
||||
else:
|
||||
status = result['status']
|
||||
time.sleep(2)
|
||||
|
||||
if status == 'invalid':
|
||||
self._fail_challenge(identifier_type, identifier, result, 'Authorization for {0} returned invalid')
|
||||
|
||||
return status == 'valid'
|
||||
|
||||
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, self.csr_content)
|
||||
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))
|
||||
|
||||
status = result['status']
|
||||
while status not in ['valid', 'invalid']:
|
||||
time.sleep(2)
|
||||
result, dummy = self.account.get_request(self.order_uri)
|
||||
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 _der_to_pem(self, der_cert):
|
||||
'''
|
||||
Convert the DER format certificate in der_cert to a PEM format
|
||||
certificate and return it.
|
||||
'''
|
||||
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
|
||||
"\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64)))
|
||||
|
||||
def _download_cert(self, url):
|
||||
'''
|
||||
Download and parse the certificate chain.
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4.2
|
||||
'''
|
||||
content, info = self.account.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}: {1} (headers: {2})".format(url, content, info))
|
||||
|
||||
cert = None
|
||||
chain = []
|
||||
|
||||
# Parse data
|
||||
certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True)
|
||||
if certs:
|
||||
cert = certs[0]
|
||||
chain = certs[1:]
|
||||
|
||||
alternates = []
|
||||
|
||||
def f(link, relation):
|
||||
if relation == 'up':
|
||||
# Process link-up headers if there was no chain in reply
|
||||
if not chain:
|
||||
chain_result, chain_info = self.account.get_request(link, parse_json_result=False)
|
||||
if chain_info['status'] in [200, 201]:
|
||||
chain.append(self._der_to_pem(chain_result))
|
||||
elif relation == 'alternate':
|
||||
alternates.append(link)
|
||||
|
||||
process_links(info, f)
|
||||
|
||||
if cert is None:
|
||||
raise ModuleFailException("Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info))
|
||||
return {'cert': cert, 'chain': chain, 'alternates': alternates}
|
||||
|
||||
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, self.csr_content)
|
||||
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]:
|
||||
del chain[:]
|
||||
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}
|
||||
|
||||
def _new_order_v2(self):
|
||||
'''
|
||||
Start a new certificate order (ACME v2 protocol).
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4
|
||||
'''
|
||||
identifiers = []
|
||||
for identifier_type, identifier in self.identifiers:
|
||||
identifiers.append({
|
||||
'type': identifier_type,
|
||||
'value': identifier,
|
||||
})
|
||||
new_order = {
|
||||
"identifiers": identifiers
|
||||
}
|
||||
result, info = self.account.send_signed_request(self.directory['newOrder'], new_order)
|
||||
|
||||
if info['status'] not in [201]:
|
||||
raise ModuleFailException("Error new order: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
|
||||
for auth_uri in result['authorizations']:
|
||||
auth_data, dummy = self.account.get_request(auth_uri)
|
||||
auth_data['uri'] = auth_uri
|
||||
identifier_type = auth_data['identifier']['type']
|
||||
identifier = auth_data['identifier']['value']
|
||||
if auth_data.get('wildcard', False):
|
||||
identifier = '*.{0}'.format(identifier)
|
||||
self.authorizations[identifier_type + ':' + identifier] = auth_data
|
||||
|
||||
self.order_uri = info['location']
|
||||
self.finalize_uri = result['finalize']
|
||||
self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
|
||||
|
||||
def is_first_step(self):
|
||||
'''
|
||||
|
@ -946,10 +642,13 @@ class ACMEClient(object):
|
|||
if identifier_type != 'dns':
|
||||
raise ModuleFailException('ACME v1 only supports DNS identifiers!')
|
||||
for identifier_type, identifier in self.identifiers:
|
||||
new_auth = self._new_authz_v1(identifier_type, identifier)
|
||||
self._add_or_update_auth(identifier_type, identifier, new_auth)
|
||||
authz = Authorization.create(self.client, identifier_type, identifier)
|
||||
self.authorizations[authz.combined_identifier] = authz
|
||||
else:
|
||||
self._new_order_v2()
|
||||
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
|
||||
|
||||
def get_challenges_data(self, first_step):
|
||||
|
@ -959,15 +658,14 @@ class ACMEClient(object):
|
|||
'''
|
||||
# Get general challenge data
|
||||
data = {}
|
||||
for type_identifier, auth in self.authorizations.items():
|
||||
identifier_type, identifier = type_identifier.split(':', 1)
|
||||
auth = self.authorizations[type_identifier]
|
||||
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 auth['status'] == 'valid':
|
||||
if authz.status == 'valid':
|
||||
continue
|
||||
# We drop the type from the key to preserve backwards compatibility
|
||||
data[identifier] = self._get_challenge_data(auth, identifier_type, identifier)
|
||||
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))
|
||||
|
@ -994,91 +692,40 @@ class ACMEClient(object):
|
|||
# For ACME v1, we attempt to create new authzs. Existing ones
|
||||
# will be returned instead.
|
||||
for identifier_type, identifier in self.identifiers:
|
||||
new_auth = self._new_authz_v1(identifier_type, identifier)
|
||||
self._add_or_update_auth(identifier_type, identifier, new_auth)
|
||||
authz = Authorization.create(self.client, identifier_type, identifier)
|
||||
self.authorizations[combine_identifier(identifier_type, identifier)] = authz
|
||||
else:
|
||||
# For ACME v2, we obtain the order object by fetching the
|
||||
# order URI, and extract the information from there.
|
||||
result, info = self.account.get_request(self.order_uri)
|
||||
self.order = Order.from_url(self.client, self.order_uri)
|
||||
self.order.load_authorizations(self.client)
|
||||
self.authorizations.update(self.order.authorizations)
|
||||
|
||||
if not result:
|
||||
raise ModuleFailException("Cannot download order from {0}: {1} (headers: {2})".format(self.order_uri, result, info))
|
||||
# Step 2: validate pending challenges
|
||||
for type_identifier, authz in self.authorizations.items():
|
||||
if authz.status == 'pending':
|
||||
identifier_type, identifier = split_identifier(type_identifier)
|
||||
authz.call_validate(self.client, self.challenge)
|
||||
self.changed = True
|
||||
|
||||
if info['status'] not in [200]:
|
||||
raise ModuleFailException("Error on downloading order: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
|
||||
for auth_uri in result['authorizations']:
|
||||
auth_data, dummy = self.account.get_request(auth_uri)
|
||||
auth_data['uri'] = auth_uri
|
||||
identifier_type = auth_data['identifier']['type']
|
||||
identifier = auth_data['identifier']['value']
|
||||
if auth_data.get('wildcard', False):
|
||||
identifier = '*.{0}'.format(identifier)
|
||||
self.authorizations[identifier_type + ':' + identifier] = auth_data
|
||||
|
||||
self.finalize_uri = result['finalize']
|
||||
|
||||
# Step 2: validate challenges
|
||||
for type_identifier, auth in self.authorizations.items():
|
||||
if auth['status'] == 'pending':
|
||||
identifier_type, identifier = type_identifier.split(':', 1)
|
||||
self._validate_challenges(identifier_type, identifier, auth)
|
||||
|
||||
def _chain_matches(self, chain, criterium):
|
||||
'''
|
||||
Check whether an alternate chain matches the specified criterium.
|
||||
'''
|
||||
if criterium['test_certificates'] == 'last':
|
||||
chain = chain[-1:]
|
||||
elif criterium['test_certificates'] == 'first':
|
||||
chain = chain[:1]
|
||||
for cert in chain:
|
||||
def download_alternate_chains(self, cert):
|
||||
alternate_chains = []
|
||||
for alternate in cert.alternates:
|
||||
try:
|
||||
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend())
|
||||
matches = True
|
||||
if criterium['subject']:
|
||||
for k, v in parse_name_field(criterium['subject']):
|
||||
oid = cryptography_name_to_oid(k)
|
||||
value = to_native(v)
|
||||
found = False
|
||||
for attribute in x509.subject:
|
||||
if attribute.oid == oid and value == to_native(attribute.value):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
matches = False
|
||||
break
|
||||
if criterium['issuer']:
|
||||
for k, v in parse_name_field(criterium['issuer']):
|
||||
oid = cryptography_name_to_oid(k)
|
||||
value = to_native(v)
|
||||
found = False
|
||||
for attribute in x509.issuer:
|
||||
if attribute.oid == oid and value == to_native(attribute.value):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
matches = False
|
||||
break
|
||||
if criterium['subject_key_identifier']:
|
||||
try:
|
||||
ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier)
|
||||
if criterium['subject_key_identifier'] != ext.value.digest:
|
||||
matches = False
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
matches = False
|
||||
if criterium['authority_key_identifier']:
|
||||
try:
|
||||
ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier)
|
||||
if criterium['authority_key_identifier'] != ext.value.key_identifier:
|
||||
matches = False
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
matches = False
|
||||
if matches:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e))
|
||||
return False
|
||||
alt_cert = CertificateChain.download(self.client, alternate)
|
||||
except ModuleFailException as e:
|
||||
self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e))
|
||||
continue
|
||||
alternate_chains.append(alt_cert)
|
||||
return alternate_chains
|
||||
|
||||
def find_matching_chain(self, chains):
|
||||
for criterium_idx, matcher in enumerate(self.select_chain_matcher):
|
||||
for chain in chains:
|
||||
if matcher.match(chain):
|
||||
self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
|
||||
return chain
|
||||
return None
|
||||
|
||||
def get_certificate(self):
|
||||
'''
|
||||
|
@ -1087,80 +734,46 @@ class ACMEClient(object):
|
|||
with an error.
|
||||
'''
|
||||
for identifier_type, identifier in self.identifiers:
|
||||
auth = self.authorizations.get(identifier_type + ':' + identifier)
|
||||
if auth is None:
|
||||
raise ModuleFailException('Found no authorization information for "{0}"!'.format(identifier_type + ':' + identifier))
|
||||
if 'status' not in auth:
|
||||
self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned no status')
|
||||
if auth['status'] != 'valid':
|
||||
self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned status ' + str(auth['status']))
|
||||
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))
|
||||
|
||||
if self.version == 1:
|
||||
cert = self._new_cert_v1()
|
||||
cert = retrieve_acme_v1_certificate(self.client, pem_to_der(self.csr, self.csr_content))
|
||||
else:
|
||||
cert_uri = self._finalize_cert()
|
||||
cert = self._download_cert(cert_uri)
|
||||
if self.module.params['retrieve_all_alternates'] or self.module.params['select_chain']:
|
||||
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 = []
|
||||
for alternate in cert['alternates']:
|
||||
try:
|
||||
alt_cert = self._download_cert(alternate)
|
||||
except ModuleFailException as e:
|
||||
self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e))
|
||||
continue
|
||||
alternate_chains.append(alt_cert)
|
||||
alternate_chains = self.download_alternate_chains(cert)
|
||||
|
||||
# Prepare return value for all alternate chains
|
||||
if self.module.params['retrieve_all_alternates']:
|
||||
self.all_chains = []
|
||||
|
||||
def _append_all_chains(cert_data):
|
||||
self.all_chains.append(dict(
|
||||
cert=cert_data['cert'].encode('utf8'),
|
||||
chain=("\n".join(cert_data.get('chain', []))).encode('utf8'),
|
||||
full_chain=(cert_data['cert'] + "\n".join(cert_data.get('chain', []))).encode('utf8'),
|
||||
))
|
||||
|
||||
_append_all_chains(cert)
|
||||
self.all_chains = [cert.to_json()]
|
||||
for alt_chain in alternate_chains:
|
||||
_append_all_chains(alt_chain)
|
||||
self.all_chains.append(alt_chain.to_json())
|
||||
|
||||
# Try to select alternate chain depending on criteria
|
||||
if self.module.params['select_chain']:
|
||||
matching_chain = None
|
||||
all_chains = [cert] + alternate_chains
|
||||
for criterium_idx, criterium in enumerate(self.module.params['select_chain']):
|
||||
for v in ('subject_key_identifier', 'authority_key_identifier'):
|
||||
if criterium[v]:
|
||||
try:
|
||||
criterium[v] = binascii.unhexlify(criterium[v].replace(':', ''))
|
||||
except Exception:
|
||||
self.module.warn('Criterium {0} in select_chain has invalid {1} value. '
|
||||
'Ignoring criterium.'.format(criterium_idx, v))
|
||||
continue
|
||||
for alt_chain in all_chains:
|
||||
if self._chain_matches(alt_chain.get('chain', []), criterium):
|
||||
self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
|
||||
matching_chain = alt_chain
|
||||
break
|
||||
if matching_chain:
|
||||
break
|
||||
if self.select_chain_matcher:
|
||||
matching_chain = self.find_matching_chain([cert] + alternate_chains)
|
||||
if matching_chain:
|
||||
cert.update(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 = list(cert.get('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 = get_cert_days(self.module, self.dest)
|
||||
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 = get_cert_days(self.module, self.fullchain_dest)
|
||||
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')):
|
||||
|
@ -1172,25 +785,14 @@ class ACMEClient(object):
|
|||
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
|
||||
https://tools.ietf.org/html/rfc8555#section-7.5.2
|
||||
'''
|
||||
authz_deactivate = {
|
||||
'status': 'deactivated'
|
||||
}
|
||||
if self.version == 1:
|
||||
authz_deactivate['resource'] = 'authz'
|
||||
if self.authorizations:
|
||||
for identifier_type, identifier in self.identifiers:
|
||||
auth = self.authorizations.get(identifier_type + ':' + identifier)
|
||||
if auth is None or auth.get('status') != 'valid':
|
||||
continue
|
||||
try:
|
||||
result, info = self.account.send_signed_request(auth['uri'], authz_deactivate)
|
||||
if 200 <= info['status'] < 300 and result.get('status') == 'deactivated':
|
||||
auth['status'] = 'deactivated'
|
||||
except Exception as dummy:
|
||||
# Ignore errors on deactivating authzs
|
||||
pass
|
||||
if auth.get('status') != 'deactivated':
|
||||
self.module.warn(warning='Could not deactivate authz object {0}.'.format(auth['uri']))
|
||||
for authz in self.authorizations.values():
|
||||
try:
|
||||
authz.deactivate(self.client)
|
||||
except Exception:
|
||||
# ignore errors
|
||||
pass
|
||||
if authz.status != 'deactivated':
|
||||
self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url))
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -1232,18 +834,13 @@ def main():
|
|||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
backend = handle_standard_module_arguments(module)
|
||||
if module.params['select_chain']:
|
||||
if backend != 'cryptography':
|
||||
module.fail_json(msg="The 'select_chain' can only be used with the 'cryptography' backend.")
|
||||
elif not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography'))
|
||||
backend = create_backend(module, False)
|
||||
|
||||
try:
|
||||
if module.params.get('dest'):
|
||||
cert_days = get_cert_days(module, module.params['dest'])
|
||||
cert_days = backend.get_cert_days(module.params['dest'])
|
||||
else:
|
||||
cert_days = get_cert_days(module, module.params['fullchain_dest'])
|
||||
cert_days = backend.get_cert_days(module.params['fullchain_dest'])
|
||||
|
||||
if module.params['force'] or cert_days < module.params['remaining_days']:
|
||||
# If checkmode is active, base the changed state solely on the status
|
||||
|
@ -1253,7 +850,7 @@ def main():
|
|||
if module.check_mode:
|
||||
module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
|
||||
else:
|
||||
client = ACMEClient(module)
|
||||
client = ACMECertificateClient(module, backend)
|
||||
client.cert_days = cert_days
|
||||
other = dict()
|
||||
is_first_step = client.is_first_step()
|
||||
|
@ -1265,7 +862,7 @@ def main():
|
|||
try:
|
||||
client.finish_challenges()
|
||||
client.get_certificate()
|
||||
if module.params['retrieve_all_alternates']:
|
||||
if client.all_chains is not None:
|
||||
other['all_chains'] = client.all_chains
|
||||
finally:
|
||||
if module.params['deactivate_authzs']:
|
||||
|
@ -1274,13 +871,13 @@ def main():
|
|||
auths = dict()
|
||||
for k, v in client.authorizations.items():
|
||||
# Remove "type:" from key
|
||||
auths[k.split(':', 1)[1]] = v
|
||||
auths[split_identifier(k)[1]] = v.to_json()
|
||||
module.exit_json(
|
||||
changed=client.changed,
|
||||
authorizations=auths,
|
||||
finalize_uri=client.finalize_uri,
|
||||
finalize_uri=client.order.finalize_uri if client.order else None,
|
||||
order_uri=client.order_uri,
|
||||
account_uri=client.account.uri,
|
||||
account_uri=client.client.account_uri,
|
||||
challenge_data=data,
|
||||
challenge_data_dns=data_dns,
|
||||
cert_days=client.cert_days,
|
||||
|
|
|
@ -119,13 +119,25 @@ RETURN = '''#'''
|
|||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
create_backend,
|
||||
get_default_argspec,
|
||||
ACMEClient,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
|
||||
ACMEAccount,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ACMEProtocolException,
|
||||
ModuleFailException,
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
handle_standard_module_arguments,
|
||||
get_default_argspec,
|
||||
)
|
||||
|
||||
|
||||
|
@ -147,10 +159,11 @@ def main():
|
|||
),
|
||||
supports_check_mode=False,
|
||||
)
|
||||
handle_standard_module_arguments(module)
|
||||
backend = create_backend(module, False)
|
||||
|
||||
try:
|
||||
account = ACMEAccount(module)
|
||||
client = ACMEClient(module, backend)
|
||||
account = ACMEAccount(client)
|
||||
# Load certificate
|
||||
certificate = pem_to_der(module.params.get('certificate'))
|
||||
certificate = nopad_b64(certificate)
|
||||
|
@ -162,25 +175,27 @@ def main():
|
|||
payload['reason'] = module.params.get('revoke_reason')
|
||||
# Determine endpoint
|
||||
if module.params.get('acme_version') == 1:
|
||||
endpoint = account.directory['revoke-cert']
|
||||
endpoint = client.directory['revoke-cert']
|
||||
payload['resource'] = 'revoke-cert'
|
||||
else:
|
||||
endpoint = account.directory['revokeCert']
|
||||
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:
|
||||
# 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)
|
||||
try:
|
||||
private_key_data = client.parse_key(private_key, private_key_content)
|
||||
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 = account.send_signed_request(endpoint, payload, key_data=private_key_data, jws_header=jws_header)
|
||||
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)
|
||||
|
@ -189,7 +204,7 @@ def main():
|
|||
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)
|
||||
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)
|
||||
|
@ -208,7 +223,7 @@ def main():
|
|||
# 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))
|
||||
raise ACMEProtocolException('Failed to revoke certificate', info=info, content_json=result)
|
||||
module.exit_json(changed=True)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
|
|
@ -140,8 +140,9 @@ import traceback
|
|||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_bytes, to_text
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
|
||||
read_file,
|
||||
)
|
||||
|
||||
|
|
|
@ -240,16 +240,18 @@ output_json:
|
|||
- ...
|
||||
'''
|
||||
|
||||
import json
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils._text import to_native, to_bytes, to_text
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
handle_standard_module_arguments,
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
create_backend,
|
||||
get_default_argspec,
|
||||
ACMEClient,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ACMEProtocolException,
|
||||
ModuleFailException,
|
||||
)
|
||||
|
||||
|
||||
|
@ -273,25 +275,26 @@ def main():
|
|||
['method', 'post', ['account_key_src', 'account_key_content'], True],
|
||||
),
|
||||
)
|
||||
handle_standard_module_arguments(module)
|
||||
backend = create_backend(module, False)
|
||||
|
||||
result = dict()
|
||||
changed = False
|
||||
try:
|
||||
# Get hold of ACMEAccount object (includes directory)
|
||||
account = ACMEAccount(module)
|
||||
# Get hold of ACMEClient and ACMEAccount objects (includes directory)
|
||||
client = ACMEClient(module, backend)
|
||||
method = module.params['method']
|
||||
result['directory'] = account.directory.directory
|
||||
result['directory'] = client.directory.directory
|
||||
# Do we have to do more requests?
|
||||
if method != 'directory-only':
|
||||
url = module.params['url']
|
||||
fail_on_acme_error = module.params['fail_on_acme_error']
|
||||
# Do request
|
||||
if method == 'get':
|
||||
data, info = account.get_request(url, parse_json_result=False, fail_on_error=False)
|
||||
data, info = client.get_request(url, parse_json_result=False, fail_on_error=False)
|
||||
elif method == 'post':
|
||||
changed = True # only POSTs can change
|
||||
data, info = account.send_signed_request(url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False)
|
||||
data, info = client.send_signed_request(
|
||||
url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False, fail_on_error=False)
|
||||
# Update results
|
||||
result.update(dict(
|
||||
headers=info,
|
||||
|
@ -299,13 +302,12 @@ def main():
|
|||
))
|
||||
# See if we can parse the result as JSON
|
||||
try:
|
||||
# to_text() is needed only for Python 3.5 (and potentially 3.0 to 3.4 as well)
|
||||
result['output_json'] = json.loads(to_text(data))
|
||||
result['output_json'] = module.from_json(to_text(data))
|
||||
except Exception as dummy:
|
||||
pass
|
||||
# Fail if error was returned
|
||||
if fail_on_acme_error and info['status'] >= 400:
|
||||
raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], data))
|
||||
raise ACMEProtocolException(info=info, content_json=result)
|
||||
# Done!
|
||||
module.exit_json(changed=changed, **result)
|
||||
except ModuleFailException as e:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
plugins/module_utils/acme/__init__.py empty-init
|
||||
plugins/module_utils/compat/ipaddress.py future-import-boilerplate
|
||||
plugins/module_utils/compat/ipaddress.py metaclass-boilerplate
|
||||
plugins/module_utils/compat/ipaddress.py no-assert
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
plugins/module_utils/acme/__init__.py empty-init
|
||||
plugins/module_utils/compat/ipaddress.py future-import-boilerplate
|
||||
plugins/module_utils/compat/ipaddress.py metaclass-boilerplate
|
||||
plugins/module_utils/compat/ipaddress.py no-assert
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
plugins/module_utils/acme/__init__.py empty-init
|
||||
plugins/module_utils/compat/ipaddress.py future-import-boilerplate
|
||||
plugins/module_utils/compat/ipaddress.py metaclass-boilerplate
|
||||
plugins/module_utils/compat/ipaddress.py no-assert
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import os
|
||||
|
||||
|
||||
def load_fixture(name):
|
||||
with open(os.path.join(os.path.dirname(__file__), 'fixtures', name)) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
TEST_PEM_DERS = [
|
||||
(
|
||||
load_fixture('privatekey_1.pem'),
|
||||
base64.b64decode('MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAo'
|
||||
'GCCqGSM49AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3'
|
||||
'lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw==')
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
TEST_KEYS = [
|
||||
(
|
||||
load_fixture('privatekey_1.pem'),
|
||||
{
|
||||
'alg': 'ES256',
|
||||
'hash': 'sha256',
|
||||
'jwk': {
|
||||
'crv': 'P-256',
|
||||
'kty': 'EC',
|
||||
'x': 'AJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68E',
|
||||
'y': 'vEEs4V0egJkNyM2Q4pp001zu14VcpQ0_Ei8xOOPxKZs',
|
||||
},
|
||||
'point_size': 32,
|
||||
'type': 'ec',
|
||||
},
|
||||
load_fixture('privatekey_1.txt'),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
TEST_CSRS = [
|
||||
(
|
||||
load_fixture('csr_1.pem'),
|
||||
set([
|
||||
('dns', 'ansible.com'),
|
||||
('dns', 'example.com'),
|
||||
('dns', 'example.org')
|
||||
]),
|
||||
load_fixture('csr_1.txt'),
|
||||
),
|
||||
(
|
||||
load_fixture('csr_2.pem'),
|
||||
set([
|
||||
('dns', 'ansible.com'),
|
||||
('ip', '127.0.0.1'),
|
||||
('ip', '::1'),
|
||||
('ip', '2001:d88:ac10:fe01::'),
|
||||
('ip', '2001:1234:5678:abcd:9876:5432:10fe:dcba')
|
||||
]),
|
||||
load_fixture('csr_2.txt'),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
TEST_CERT = load_fixture("cert_1.pem")
|
||||
|
||||
|
||||
TEST_CERT_DAYS = [
|
||||
(datetime.datetime(2018, 11, 15, 1, 2, 3), 11),
|
||||
(datetime.datetime(2018, 11, 25, 15, 20, 0), 1),
|
||||
(datetime.datetime(2018, 11, 25, 15, 30, 0), 0),
|
||||
]
|
|
@ -1,216 +0,0 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import os.path
|
||||
import pytest
|
||||
|
||||
from mock import MagicMock
|
||||
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
nopad_b64,
|
||||
write_file,
|
||||
read_file,
|
||||
pem_to_der,
|
||||
_parse_key_openssl,
|
||||
# _sign_request_openssl,
|
||||
_parse_key_cryptography,
|
||||
# _sign_request_cryptography,
|
||||
_normalize_ip,
|
||||
openssl_get_csr_identifiers,
|
||||
cryptography_get_csr_identifiers,
|
||||
cryptography_get_cert_days,
|
||||
)
|
||||
|
||||
|
||||
def load_fixture(name):
|
||||
with open(os.path.join(os.path.dirname(__file__), 'fixtures', name)) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
NOPAD_B64 = [
|
||||
("", ""),
|
||||
("\n", "Cg"),
|
||||
("123", "MTIz"),
|
||||
("Lorem?ipsum", "TG9yZW0_aXBzdW0"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", NOPAD_B64)
|
||||
def test_nopad_b64(value, result):
|
||||
assert nopad_b64(value.encode('utf-8')) == result
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
TEST_TEXT = r"""1234
|
||||
5678"""
|
||||
|
||||
|
||||
def test_read_file(tmpdir):
|
||||
fn = tmpdir / 'test.txt'
|
||||
fn.write(TEST_TEXT)
|
||||
assert read_file(str(fn), 't') == TEST_TEXT
|
||||
assert read_file(str(fn), 'b') == TEST_TEXT.encode('utf-8')
|
||||
|
||||
|
||||
def test_write_file(tmpdir):
|
||||
fn = tmpdir / 'test.txt'
|
||||
module = MagicMock()
|
||||
write_file(module, str(fn), TEST_TEXT.encode('utf-8'))
|
||||
assert fn.read() == TEST_TEXT
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
TEST_PEM_DERS = [
|
||||
(
|
||||
load_fixture('privatekey_1.pem'),
|
||||
base64.b64decode('MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAo'
|
||||
'GCCqGSM49AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3'
|
||||
'lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw==')
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pem, der", TEST_PEM_DERS)
|
||||
def test_pem_to_der(pem, der, tmpdir):
|
||||
fn = tmpdir / 'test.pem'
|
||||
fn.write(pem)
|
||||
assert pem_to_der(str(fn)) == der
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
TEST_KEYS = [
|
||||
(
|
||||
load_fixture('privatekey_1.pem'),
|
||||
{
|
||||
'alg': 'ES256',
|
||||
'hash': 'sha256',
|
||||
'jwk': {
|
||||
'crv': 'P-256',
|
||||
'kty': 'EC',
|
||||
'x': 'AJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68E',
|
||||
'y': 'vEEs4V0egJkNyM2Q4pp001zu14VcpQ0_Ei8xOOPxKZs',
|
||||
},
|
||||
'point_size': 32,
|
||||
'type': 'ec',
|
||||
},
|
||||
load_fixture('privatekey_1.txt'),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pem, result, openssl_output", TEST_KEYS)
|
||||
def test_eckeyparse_openssl(pem, result, openssl_output, tmpdir):
|
||||
fn = tmpdir / 'test.key'
|
||||
fn.write(pem)
|
||||
module = MagicMock()
|
||||
module.run_command = MagicMock(return_value=(0, openssl_output, 0))
|
||||
error, key = _parse_key_openssl('openssl', module, key_file=str(fn))
|
||||
assert error is None
|
||||
key.pop('key_file')
|
||||
assert key == result
|
||||
|
||||
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
@pytest.mark.parametrize("pem, result, dummy", TEST_KEYS)
|
||||
def test_eckeyparse_cryptography(pem, result, dummy):
|
||||
module = MagicMock()
|
||||
error, key = _parse_key_cryptography(module, key_content=pem)
|
||||
assert error is None
|
||||
key.pop('key_obj')
|
||||
assert key == result
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
TEST_IPS = [
|
||||
("0:0:0:0:0:0:0:1", "::1"),
|
||||
("1::0:2", "1::2"),
|
||||
("0000:0001:0000:0000:0000:0000:0000:0001", "0:1::1"),
|
||||
("0000:0001:0000:0000:0001:0000:0000:0001", "0:1::1:0:0:1"),
|
||||
("0000:0001:0000:0001:0000:0001:0000:0001", "0:1:0:1:0:1:0:1"),
|
||||
("0.0.0.0", "0.0.0.0"),
|
||||
("000.001.000.000", "0.1.0.0"),
|
||||
("2001:d88:ac10:fe01:0:0:0:0", "2001:d88:ac10:fe01::"),
|
||||
("0000:0000:0000:0000:0000:0000:0000:0000", "::"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip, result", TEST_IPS)
|
||||
def test_normalize_ip(ip, result):
|
||||
assert _normalize_ip(ip) == result
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
TEST_CSRS = [
|
||||
(
|
||||
load_fixture('csr_1.pem'),
|
||||
set([
|
||||
('dns', 'ansible.com'),
|
||||
('dns', 'example.com'),
|
||||
('dns', 'example.org')
|
||||
]),
|
||||
load_fixture('csr_1.txt'),
|
||||
),
|
||||
(
|
||||
load_fixture('csr_2.pem'),
|
||||
set([
|
||||
('dns', 'ansible.com'),
|
||||
('ip', '127.0.0.1'),
|
||||
('ip', '::1'),
|
||||
('ip', '2001:d88:ac10:fe01::'),
|
||||
('ip', '2001:1234:5678:abcd:9876:5432:10fe:dcba')
|
||||
]),
|
||||
load_fixture('csr_2.txt'),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS)
|
||||
def test_csridentifiers_openssl(csr, result, openssl_output, tmpdir):
|
||||
fn = tmpdir / 'test.csr'
|
||||
fn.write(csr)
|
||||
module = MagicMock()
|
||||
module.run_command = MagicMock(return_value=(0, openssl_output, 0))
|
||||
identifiers = openssl_get_csr_identifiers('openssl', module, str(fn))
|
||||
assert identifiers == result
|
||||
|
||||
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS)
|
||||
def test_csridentifiers_cryptography(csr, result, openssl_output, tmpdir):
|
||||
fn = tmpdir / 'test.csr'
|
||||
fn.write(csr)
|
||||
module = MagicMock()
|
||||
identifiers = cryptography_get_csr_identifiers(module, str(fn))
|
||||
assert identifiers == result
|
||||
|
||||
|
||||
################################################
|
||||
|
||||
TEST_CERT = load_fixture("cert_1.pem")
|
||||
|
||||
TEST_CERT_DAYS = [
|
||||
(datetime.datetime(2018, 11, 15, 1, 2, 3), 11),
|
||||
(datetime.datetime(2018, 11, 25, 15, 20, 0), 1),
|
||||
(datetime.datetime(2018, 11, 25, 15, 30, 0), 0),
|
||||
]
|
||||
|
||||
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
@pytest.mark.parametrize("now, expected_days", TEST_CERT_DAYS)
|
||||
def test_certdays_cryptography(now, expected_days, tmpdir):
|
||||
fn = tmpdir / 'test-cert.pem'
|
||||
fn.write(TEST_CERT)
|
||||
module = MagicMock()
|
||||
days = cryptography_get_cert_days(module, str(fn), now=now)
|
||||
assert days == expected_days
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from mock import MagicMock
|
||||
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
CryptographyBackend,
|
||||
)
|
||||
|
||||
from .backend_data import (
|
||||
TEST_KEYS,
|
||||
TEST_CSRS,
|
||||
TEST_CERT,
|
||||
TEST_CERT_DAYS,
|
||||
)
|
||||
|
||||
|
||||
if not HAS_CURRENT_CRYPTOGRAPHY:
|
||||
pytest.skip('cryptography not found')
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pem, result, dummy", TEST_KEYS)
|
||||
def test_eckeyparse_cryptography(pem, result, dummy, tmpdir):
|
||||
fn = tmpdir / 'test.pem'
|
||||
fn.write(pem)
|
||||
module = MagicMock()
|
||||
backend = CryptographyBackend(module)
|
||||
error, key = backend.parse_key(key_file=str(fn))
|
||||
assert error is None
|
||||
key.pop('key_obj')
|
||||
assert key == result
|
||||
error, key = backend.parse_key(key_content=pem)
|
||||
assert error is None
|
||||
key.pop('key_obj')
|
||||
assert key == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS)
|
||||
def test_csridentifiers_cryptography(csr, result, openssl_output, tmpdir):
|
||||
fn = tmpdir / 'test.csr'
|
||||
fn.write(csr)
|
||||
module = MagicMock()
|
||||
backend = CryptographyBackend(module)
|
||||
identifiers = backend.get_csr_identifiers(csr_filename=str(fn))
|
||||
assert identifiers == result
|
||||
identifiers = backend.get_csr_identifiers(csr_content=csr)
|
||||
assert identifiers == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("now, expected_days", TEST_CERT_DAYS)
|
||||
def test_certdays_cryptography(now, expected_days, tmpdir):
|
||||
fn = tmpdir / 'test-cert.pem'
|
||||
fn.write(TEST_CERT)
|
||||
module = MagicMock()
|
||||
backend = CryptographyBackend(module)
|
||||
days = backend.get_cert_days(cert_filename=str(fn), now=now)
|
||||
assert days == expected_days
|
||||
days = backend.get_cert_days(cert_content=TEST_CERT, now=now)
|
||||
assert days == expected_days
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from mock import MagicMock
|
||||
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from .backend_data import (
|
||||
TEST_KEYS,
|
||||
TEST_CSRS,
|
||||
)
|
||||
|
||||
|
||||
TEST_IPS = [
|
||||
("0:0:0:0:0:0:0:1", "::1"),
|
||||
("1::0:2", "1::2"),
|
||||
("0000:0001:0000:0000:0000:0000:0000:0001", "0:1::1"),
|
||||
("0000:0001:0000:0000:0001:0000:0000:0001", "0:1::1:0:0:1"),
|
||||
("0000:0001:0000:0001:0000:0001:0000:0001", "0:1:0:1:0:1:0:1"),
|
||||
("0.0.0.0", "0.0.0.0"),
|
||||
("000.001.000.000", "0.1.0.0"),
|
||||
("2001:d88:ac10:fe01:0:0:0:0", "2001:d88:ac10:fe01::"),
|
||||
("0000:0000:0000:0000:0000:0000:0000:0000", "::"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pem, result, openssl_output", TEST_KEYS)
|
||||
def test_eckeyparse_openssl(pem, result, openssl_output, tmpdir):
|
||||
fn = tmpdir / 'test.key'
|
||||
fn.write(pem)
|
||||
module = MagicMock()
|
||||
module.run_command = MagicMock(return_value=(0, openssl_output, 0))
|
||||
backend = OpenSSLCLIBackend(module, openssl_binary='openssl')
|
||||
error, key = backend.parse_key(key_file=str(fn))
|
||||
assert error is None
|
||||
key.pop('key_file')
|
||||
assert key == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS)
|
||||
def test_csridentifiers_openssl(csr, result, openssl_output, tmpdir):
|
||||
fn = tmpdir / 'test.csr'
|
||||
fn.write(csr)
|
||||
module = MagicMock()
|
||||
module.run_command = MagicMock(return_value=(0, openssl_output, 0))
|
||||
backend = OpenSSLCLIBackend(module, openssl_binary='openssl')
|
||||
identifiers = backend.get_csr_identifiers(str(fn))
|
||||
assert identifiers == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip, result", TEST_IPS)
|
||||
def test_normalize_ip(ip, result):
|
||||
module = MagicMock()
|
||||
backend = OpenSSLCLIBackend(module, openssl_binary='openssl')
|
||||
assert backend._normalize_ip(ip) == result
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
from mock import MagicMock
|
||||
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
|
||||
read_file,
|
||||
write_file,
|
||||
)
|
||||
|
||||
|
||||
TEST_TEXT = r"""1234
|
||||
5678"""
|
||||
|
||||
|
||||
def test_read_file(tmpdir):
|
||||
fn = tmpdir / 'test.txt'
|
||||
fn.write(TEST_TEXT)
|
||||
assert read_file(str(fn), 't') == TEST_TEXT
|
||||
assert read_file(str(fn), 'b') == TEST_TEXT.encode('utf-8')
|
||||
|
||||
|
||||
def test_write_file(tmpdir):
|
||||
fn = tmpdir / 'test.txt'
|
||||
module = MagicMock()
|
||||
write_file(module, str(fn), TEST_TEXT.encode('utf-8'))
|
||||
assert fn.read() == TEST_TEXT
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
)
|
||||
|
||||
from .backend_data import (
|
||||
TEST_PEM_DERS,
|
||||
)
|
||||
|
||||
|
||||
NOPAD_B64 = [
|
||||
("", ""),
|
||||
("\n", "Cg"),
|
||||
("123", "MTIz"),
|
||||
("Lorem?ipsum", "TG9yZW0_aXBzdW0"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, result", NOPAD_B64)
|
||||
def test_nopad_b64(value, result):
|
||||
assert nopad_b64(value.encode('utf-8')) == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pem, der", TEST_PEM_DERS)
|
||||
def test_pem_to_der(pem, der, tmpdir):
|
||||
fn = tmpdir / 'test.pem'
|
||||
fn.write(pem)
|
||||
assert pem_to_der(str(fn)) == der
|
Loading…
Reference in New Issue