mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-05-06 13:22:58 +00:00
Initial commit
This commit is contained in:
0
plugins/doc_fragments/__init__.py
Normal file
0
plugins/doc_fragments/__init__.py
Normal file
113
plugins/doc_fragments/acme.py
Normal file
113
plugins/doc_fragments/acme.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# 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
|
||||
|
||||
|
||||
class ModuleDocFragment(object):
|
||||
|
||||
# Standard files documentation fragment
|
||||
DOCUMENTATION = r'''
|
||||
notes:
|
||||
- "If a new enough version of the C(cryptography) library
|
||||
is available (see Requirements for details), it will be used
|
||||
instead of the C(openssl) binary. This can be explicitly disabled
|
||||
or enabled with the C(select_crypto_backend) option. Note that using
|
||||
the C(openssl) binary will be slower and less secure, as private key
|
||||
contents always have to be stored on disk (see
|
||||
C(account_key_content))."
|
||||
- "Although the defaults are chosen so that the module can be used with
|
||||
the L(Let's Encrypt,https://letsencrypt.org/) CA, the module can in
|
||||
principle be used with any CA providing an ACME endpoint, such as
|
||||
L(Buypass Go SSL,https://www.buypass.com/ssl/products/acme)."
|
||||
requirements:
|
||||
- python >= 2.6
|
||||
- either openssl or L(cryptography,https://cryptography.io/) >= 1.5
|
||||
options:
|
||||
account_key_src:
|
||||
description:
|
||||
- "Path to a file containing the ACME account RSA or Elliptic Curve
|
||||
key."
|
||||
- "RSA keys can be created with C(openssl genrsa ...). Elliptic curve keys can
|
||||
be created with C(openssl ecparam -genkey ...). Any other tool creating
|
||||
private keys in PEM format can be used as well."
|
||||
- "Mutually exclusive with C(account_key_content)."
|
||||
- "Required if C(account_key_content) is not used."
|
||||
type: path
|
||||
aliases: [ account_key ]
|
||||
account_key_content:
|
||||
description:
|
||||
- "Content of the ACME account RSA or Elliptic Curve key."
|
||||
- "Mutually exclusive with C(account_key_src)."
|
||||
- "Required if C(account_key_src) is not used."
|
||||
- "*Warning:* the content will be written into a temporary file, which will
|
||||
be deleted by Ansible when the module completes. Since this is an
|
||||
important private key — it can be used to change the account key,
|
||||
or to revoke your certificates without knowing their private keys
|
||||
—, this might not be acceptable."
|
||||
- "In case C(cryptography) is used, the content is not written into a
|
||||
temporary file. It can still happen that it is written to disk by
|
||||
Ansible in the process of moving the module with its argument to
|
||||
the node where it is executed."
|
||||
type: str
|
||||
account_uri:
|
||||
description:
|
||||
- "If specified, assumes that the account URI is as given. If the
|
||||
account key does not match this account, or an account with this
|
||||
URI does not exist, the module fails."
|
||||
type: str
|
||||
acme_version:
|
||||
description:
|
||||
- "The ACME version of the endpoint."
|
||||
- "Must be 1 for the classic Let's Encrypt and Buypass ACME endpoints,
|
||||
or 2 for standardized ACME v2 endpoints."
|
||||
- "The default value is 1. Note that in Ansible 2.14, this option *will
|
||||
be required* and will no longer have a default."
|
||||
- "Please also note that we will deprecate ACME v1 support eventually."
|
||||
type: int
|
||||
choices: [ 1, 2 ]
|
||||
acme_directory:
|
||||
description:
|
||||
- "The ACME directory to use. This is the entry point URL to access
|
||||
CA server API."
|
||||
- "For safety reasons the default is set to the Let's Encrypt staging
|
||||
server (for the ACME v1 protocol). This will create technically correct,
|
||||
but untrusted certificates."
|
||||
- "The default value is U(https://acme-staging.api.letsencrypt.org/directory).
|
||||
Note that in Ansible 2.14, this option *will be required* and will no longer
|
||||
have a default."
|
||||
- "For Let's Encrypt, all staging endpoints can be found here:
|
||||
U(https://letsencrypt.org/docs/staging-environment/). For Buypass, all
|
||||
endpoints can be found here:
|
||||
U(https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints)"
|
||||
- "For Let's Encrypt, the production directory URL for ACME v1 is
|
||||
U(https://acme-v01.api.letsencrypt.org/directory), and the production
|
||||
directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory)."
|
||||
- "For Buypass, the production directory URL for ACME v2 and v1 is
|
||||
U(https://api.buypass.com/acme/directory)."
|
||||
- "*Warning:* So far, the module has only been tested against Let's Encrypt
|
||||
(staging and production), Buypass (staging and production), and
|
||||
L(Pebble testing server,https://github.com/letsencrypt/Pebble)."
|
||||
type: str
|
||||
validate_certs:
|
||||
description:
|
||||
- Whether calls to the ACME directory will validate TLS certificates.
|
||||
- "*Warning:* Should *only ever* be set to C(no) for testing purposes,
|
||||
for example when testing against a local Pebble server."
|
||||
type: bool
|
||||
default: yes
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to
|
||||
C(openssl).
|
||||
- If set to C(openssl), will try to use the C(openssl) binary.
|
||||
- If set to C(cryptography), will try to use the
|
||||
L(cryptography,https://cryptography.io/) library.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, openssl ]
|
||||
'''
|
||||
43
plugins/doc_fragments/ecs_credential.py
Normal file
43
plugins/doc_fragments/ecs_credential.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c), Entrust Datacard Corporation, 2019
|
||||
# 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
|
||||
|
||||
|
||||
class ModuleDocFragment(object):
|
||||
|
||||
# Plugin options for Entrust Certificate Services (ECS) credentials
|
||||
DOCUMENTATION = r'''
|
||||
options:
|
||||
entrust_api_user:
|
||||
description:
|
||||
- The username for authentication to the Entrust Certificate Services (ECS) API.
|
||||
type: str
|
||||
required: true
|
||||
entrust_api_key:
|
||||
description:
|
||||
- The key (password) for authentication to the Entrust Certificate Services (ECS) API.
|
||||
type: str
|
||||
required: true
|
||||
entrust_api_client_cert_path:
|
||||
description:
|
||||
- The path to the client certificate used to authenticate to the Entrust Certificate Services (ECS) API.
|
||||
type: path
|
||||
required: true
|
||||
entrust_api_client_cert_key_path:
|
||||
description:
|
||||
- The path to the key for the client certificate used to authenticate to the Entrust Certificate Services (ECS) API.
|
||||
type: path
|
||||
required: true
|
||||
entrust_api_specification_path:
|
||||
description:
|
||||
- The path to the specification file defining the Entrust Certificate Services (ECS) API configuration.
|
||||
- You can use this to keep a local copy of the specification to avoid downloading it every time the module is used.
|
||||
type: path
|
||||
default: https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml
|
||||
requirements:
|
||||
- "PyYAML >= 3.11"
|
||||
'''
|
||||
0
plugins/module_utils/__init__.py
Normal file
0
plugins/module_utils/__init__.py
Normal file
1028
plugins/module_utils/acme.py
Normal file
1028
plugins/module_utils/acme.py
Normal file
File diff suppressed because it is too large
Load Diff
2125
plugins/module_utils/crypto.py
Normal file
2125
plugins/module_utils/crypto.py
Normal file
File diff suppressed because it is too large
Load Diff
364
plugins/module_utils/ecs/api.py
Normal file
364
plugins/module_utils/ecs/api.py
Normal file
@@ -0,0 +1,364 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This code is part of Ansible, but is an independent component.
|
||||
# This particular file snippet, and this file snippet only, is licensed under the
|
||||
# Modified BSD License. Modules you write using this snippet, which is embedded
|
||||
# dynamically by Ansible, still belong to the author of the module, and may assign
|
||||
# their own license to the complete work.
|
||||
#
|
||||
# Copyright (c), Entrust Datacard Corporation, 2019
|
||||
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
|
||||
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils._text import to_text, to_native
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.six.moves.urllib.parse import urlencode
|
||||
from ansible.module_utils.six.moves.urllib.error import HTTPError
|
||||
from ansible.module_utils.urls import Request
|
||||
|
||||
YAML_IMP_ERR = None
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
YAML_FOUND = False
|
||||
YAML_IMP_ERR = traceback.format_exc()
|
||||
else:
|
||||
YAML_FOUND = True
|
||||
|
||||
valid_file_format = re.compile(r".*(\.)(yml|yaml|json)$")
|
||||
|
||||
|
||||
def ecs_client_argument_spec():
|
||||
return dict(
|
||||
entrust_api_user=dict(type='str', required=True),
|
||||
entrust_api_key=dict(type='str', required=True, no_log=True),
|
||||
entrust_api_client_cert_path=dict(type='path', required=True),
|
||||
entrust_api_client_cert_key_path=dict(type='path', required=True, no_log=True),
|
||||
entrust_api_specification_path=dict(type='path', default='https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml'),
|
||||
)
|
||||
|
||||
|
||||
class SessionConfigurationException(Exception):
|
||||
""" Raised if we cannot configure a session with the API """
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RestOperationException(Exception):
|
||||
""" Encapsulate a REST API error """
|
||||
|
||||
def __init__(self, error):
|
||||
self.status = to_native(error.get("status", None))
|
||||
self.errors = [to_native(err.get("message")) for err in error.get("errors", {})]
|
||||
self.message = to_native(" ".join(self.errors))
|
||||
|
||||
|
||||
def generate_docstring(operation_spec):
|
||||
"""Generate a docstring for an operation defined in operation_spec (swagger)"""
|
||||
# Description of the operation
|
||||
docs = operation_spec.get("description", "No Description")
|
||||
docs += "\n\n"
|
||||
|
||||
# Parameters of the operation
|
||||
parameters = operation_spec.get("parameters", [])
|
||||
if len(parameters) != 0:
|
||||
docs += "\tArguments:\n\n"
|
||||
for parameter in parameters:
|
||||
docs += "{0} ({1}:{2}): {3}\n".format(
|
||||
parameter.get("name"),
|
||||
parameter.get("type", "No Type"),
|
||||
"Required" if parameter.get("required", False) else "Not Required",
|
||||
parameter.get("description"),
|
||||
)
|
||||
|
||||
return docs
|
||||
|
||||
|
||||
def bind(instance, method, operation_spec):
|
||||
def binding_scope_fn(*args, **kwargs):
|
||||
return method(instance, *args, **kwargs)
|
||||
|
||||
# Make sure we don't confuse users; add the proper name and documentation to the function.
|
||||
# Users can use !help(<function>) to get help on the function from interactive python or pdb
|
||||
operation_name = operation_spec.get("operationId").split("Using")[0]
|
||||
binding_scope_fn.__name__ = str(operation_name)
|
||||
binding_scope_fn.__doc__ = generate_docstring(operation_spec)
|
||||
|
||||
return binding_scope_fn
|
||||
|
||||
|
||||
class RestOperation(object):
|
||||
def __init__(self, session, uri, method, parameters=None):
|
||||
self.session = session
|
||||
self.method = method
|
||||
if parameters is None:
|
||||
self.parameters = {}
|
||||
else:
|
||||
self.parameters = parameters
|
||||
self.url = "{scheme}://{host}{base_path}{uri}".format(scheme="https", host=session._spec.get("host"), base_path=session._spec.get("basePath"), uri=uri)
|
||||
|
||||
def restmethod(self, *args, **kwargs):
|
||||
"""Do the hard work of making the request here"""
|
||||
|
||||
# gather named path parameters and do substitution on the URL
|
||||
if self.parameters:
|
||||
path_parameters = {}
|
||||
body_parameters = {}
|
||||
query_parameters = {}
|
||||
for x in self.parameters:
|
||||
expected_location = x.get("in")
|
||||
key_name = x.get("name", None)
|
||||
key_value = kwargs.get(key_name, None)
|
||||
if expected_location == "path" and key_name and key_value:
|
||||
path_parameters.update({key_name: key_value})
|
||||
elif expected_location == "body" and key_name and key_value:
|
||||
body_parameters.update({key_name: key_value})
|
||||
elif expected_location == "query" and key_name and key_value:
|
||||
query_parameters.update({key_name: key_value})
|
||||
|
||||
if len(body_parameters.keys()) >= 1:
|
||||
body_parameters = body_parameters.get(list(body_parameters.keys())[0])
|
||||
else:
|
||||
body_parameters = None
|
||||
else:
|
||||
path_parameters = {}
|
||||
query_parameters = {}
|
||||
body_parameters = None
|
||||
|
||||
# This will fail if we have not set path parameters with a KeyError
|
||||
url = self.url.format(**path_parameters)
|
||||
if query_parameters:
|
||||
# modify the URL to add path parameters
|
||||
url = url + "?" + urlencode(query_parameters)
|
||||
|
||||
try:
|
||||
if body_parameters:
|
||||
body_parameters_json = json.dumps(body_parameters)
|
||||
response = self.session.request.open(method=self.method, url=url, data=body_parameters_json)
|
||||
else:
|
||||
response = self.session.request.open(method=self.method, url=url)
|
||||
request_error = False
|
||||
except HTTPError as e:
|
||||
# An HTTPError has the same methods available as a valid response from request.open
|
||||
response = e
|
||||
request_error = True
|
||||
|
||||
# Return the result if JSON and success ({} for empty responses)
|
||||
# Raise an exception if there was a failure.
|
||||
try:
|
||||
result_code = response.getcode()
|
||||
result = json.loads(response.read())
|
||||
except ValueError:
|
||||
result = {}
|
||||
|
||||
if result or result == {}:
|
||||
if result_code and result_code < 400:
|
||||
return result
|
||||
else:
|
||||
raise RestOperationException(result)
|
||||
|
||||
# Raise a generic RestOperationException if this fails
|
||||
raise RestOperationException({"status": result_code, "errors": [{"message": "REST Operation Failed"}]})
|
||||
|
||||
|
||||
class Resource(object):
|
||||
""" Implement basic CRUD operations against a path. """
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
self.parameters = {}
|
||||
|
||||
for url in session._spec.get("paths").keys():
|
||||
methods = session._spec.get("paths").get(url)
|
||||
for method in methods.keys():
|
||||
operation_spec = methods.get(method)
|
||||
operation_name = operation_spec.get("operationId", None)
|
||||
parameters = operation_spec.get("parameters")
|
||||
|
||||
if not operation_name:
|
||||
if method.lower() == "post":
|
||||
operation_name = "Create"
|
||||
elif method.lower() == "get":
|
||||
operation_name = "Get"
|
||||
elif method.lower() == "put":
|
||||
operation_name = "Update"
|
||||
elif method.lower() == "delete":
|
||||
operation_name = "Delete"
|
||||
elif method.lower() == "patch":
|
||||
operation_name = "Patch"
|
||||
else:
|
||||
raise SessionConfigurationException(to_native("Invalid REST method type {0}".format(method)))
|
||||
|
||||
# Get the non-parameter parts of the URL and append to the operation name
|
||||
# e.g /application/version -> GetApplicationVersion
|
||||
# e.g. /application/{id} -> GetApplication
|
||||
# This may lead to duplicates, which we must prevent.
|
||||
operation_name += re.sub(r"{(.*)}", "", url).replace("/", " ").title().replace(" ", "")
|
||||
operation_spec["operationId"] = operation_name
|
||||
|
||||
op = RestOperation(session, url, method, parameters)
|
||||
setattr(self, operation_name, bind(self, op.restmethod, operation_spec))
|
||||
|
||||
|
||||
# Session to encapsulate the connection parameters of the module_utils Request object, the api spec, etc
|
||||
class ECSSession(object):
|
||||
def __init__(self, name, **kwargs):
|
||||
"""
|
||||
Initialize our session
|
||||
"""
|
||||
|
||||
self._set_config(name, **kwargs)
|
||||
|
||||
def client(self):
|
||||
resource = Resource(self)
|
||||
return resource
|
||||
|
||||
def _set_config(self, name, **kwargs):
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
self.request = Request(headers=headers, timeout=60)
|
||||
|
||||
configurators = [self._read_config_vars]
|
||||
for configurator in configurators:
|
||||
self._config = configurator(name, **kwargs)
|
||||
if self._config:
|
||||
break
|
||||
if self._config is None:
|
||||
raise SessionConfigurationException(to_native("No Configuration Found."))
|
||||
|
||||
# set up auth if passed
|
||||
entrust_api_user = self.get_config("entrust_api_user")
|
||||
entrust_api_key = self.get_config("entrust_api_key")
|
||||
if entrust_api_user and entrust_api_key:
|
||||
self.request.url_username = entrust_api_user
|
||||
self.request.url_password = entrust_api_key
|
||||
else:
|
||||
raise SessionConfigurationException(to_native("User and key must be provided."))
|
||||
|
||||
# set up client certificate if passed (support all-in one or cert + key)
|
||||
entrust_api_cert = self.get_config("entrust_api_cert")
|
||||
entrust_api_cert_key = self.get_config("entrust_api_cert_key")
|
||||
if entrust_api_cert:
|
||||
self.request.client_cert = entrust_api_cert
|
||||
if entrust_api_cert_key:
|
||||
self.request.client_key = entrust_api_cert_key
|
||||
else:
|
||||
raise SessionConfigurationException(to_native("Client certificate for authentication to the API must be provided."))
|
||||
|
||||
# set up the spec
|
||||
entrust_api_specification_path = self.get_config("entrust_api_specification_path")
|
||||
|
||||
if not entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path):
|
||||
raise SessionConfigurationException(to_native("OpenAPI specification was not found at location {0}.".format(entrust_api_specification_path)))
|
||||
if not valid_file_format.match(entrust_api_specification_path):
|
||||
raise SessionConfigurationException(to_native("OpenAPI specification filename must end in .json, .yml or .yaml"))
|
||||
|
||||
self.verify = True
|
||||
|
||||
if entrust_api_specification_path.startswith("http"):
|
||||
try:
|
||||
http_response = Request().open(method="GET", url=entrust_api_specification_path)
|
||||
http_response_contents = http_response.read()
|
||||
if entrust_api_specification_path.endswith(".json"):
|
||||
self._spec = json.load(http_response_contents)
|
||||
elif entrust_api_specification_path.endswith(".yml") or entrust_api_specification_path.endswith(".yaml"):
|
||||
self._spec = yaml.safe_load(http_response_contents)
|
||||
except HTTPError as e:
|
||||
raise SessionConfigurationException(to_native("Error downloading specification from address '{0}', received error code '{1}'".format(
|
||||
entrust_api_specification_path, e.getcode())))
|
||||
else:
|
||||
with open(entrust_api_specification_path) as f:
|
||||
if ".json" in entrust_api_specification_path:
|
||||
self._spec = json.load(f)
|
||||
elif ".yml" in entrust_api_specification_path or ".yaml" in entrust_api_specification_path:
|
||||
self._spec = yaml.safe_load(f)
|
||||
|
||||
def get_config(self, item):
|
||||
return self._config.get(item, None)
|
||||
|
||||
def _read_config_vars(self, name, **kwargs):
|
||||
""" Read configuration from variables passed to the module. """
|
||||
config = {}
|
||||
|
||||
entrust_api_specification_path = kwargs.get("entrust_api_specification_path")
|
||||
if not entrust_api_specification_path or (not entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path)):
|
||||
raise SessionConfigurationException(
|
||||
to_native(
|
||||
"Parameter provided for entrust_api_specification_path of value '{0}' was not a valid file path or HTTPS address.".format(
|
||||
entrust_api_specification_path
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for required_file in ["entrust_api_cert", "entrust_api_cert_key"]:
|
||||
file_path = kwargs.get(required_file)
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
raise SessionConfigurationException(
|
||||
to_native("Parameter provided for {0} of value '{1}' was not a valid file path.".format(required_file, file_path))
|
||||
)
|
||||
|
||||
for required_var in ["entrust_api_user", "entrust_api_key"]:
|
||||
if not kwargs.get(required_var):
|
||||
raise SessionConfigurationException(to_native("Parameter provided for {0} was missing.".format(required_var)))
|
||||
|
||||
config["entrust_api_cert"] = kwargs.get("entrust_api_cert")
|
||||
config["entrust_api_cert_key"] = kwargs.get("entrust_api_cert_key")
|
||||
config["entrust_api_specification_path"] = kwargs.get("entrust_api_specification_path")
|
||||
config["entrust_api_user"] = kwargs.get("entrust_api_user")
|
||||
config["entrust_api_key"] = kwargs.get("entrust_api_key")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def ECSClient(entrust_api_user=None, entrust_api_key=None, entrust_api_cert=None, entrust_api_cert_key=None, entrust_api_specification_path=None):
|
||||
"""Create an ECS client"""
|
||||
|
||||
if not YAML_FOUND:
|
||||
raise SessionConfigurationException(missing_required_lib("PyYAML"), exception=YAML_IMP_ERR)
|
||||
|
||||
if entrust_api_specification_path is None:
|
||||
entrust_api_specification_path = "https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml"
|
||||
|
||||
# Not functionally necessary with current uses of this module_util, but better to be explicit for future use cases
|
||||
entrust_api_user = to_text(entrust_api_user)
|
||||
entrust_api_key = to_text(entrust_api_key)
|
||||
entrust_api_cert_key = to_text(entrust_api_cert_key)
|
||||
entrust_api_specification_path = to_text(entrust_api_specification_path)
|
||||
|
||||
return ECSSession(
|
||||
"ecs",
|
||||
entrust_api_user=entrust_api_user,
|
||||
entrust_api_key=entrust_api_key,
|
||||
entrust_api_cert=entrust_api_cert,
|
||||
entrust_api_cert_key=entrust_api_cert_key,
|
||||
entrust_api_specification_path=entrust_api_specification_path,
|
||||
).client()
|
||||
0
plugins/modules/__init__.py
Normal file
0
plugins/modules/__init__.py
Normal file
278
plugins/modules/acme_account.py
Normal file
278
plugins/modules/acme_account.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: acme_account
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
short_description: Create, modify or delete ACME accounts
|
||||
description:
|
||||
- "Allows to create, modify or delete accounts with a CA supporting the
|
||||
L(ACME protocol,https://tools.ietf.org/html/rfc8555),
|
||||
such as L(Let's Encrypt,https://letsencrypt.org/)."
|
||||
- "This module only works with the ACME v2 protocol."
|
||||
notes:
|
||||
- "The M(acme_certificate) module also allows to do basic account management.
|
||||
When using both modules, it is recommended to disable account management
|
||||
for M(acme_certificate). For that, use the C(modify_account) option of
|
||||
M(acme_certificate)."
|
||||
seealso:
|
||||
- name: Automatic Certificate Management Environment (ACME)
|
||||
description: The specification of the ACME protocol (RFC 8555).
|
||||
link: https://tools.ietf.org/html/rfc8555
|
||||
- module: acme_account_info
|
||||
description: Retrieves facts about an ACME account.
|
||||
- module: openssl_privatekey
|
||||
description: Can be used to create a private account key.
|
||||
- module: acme_inspect
|
||||
description: Allows to debug problems.
|
||||
extends_documentation_fragment:
|
||||
- community.crypto.acme
|
||||
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- "The state of the account, to be identified by its account key."
|
||||
- "If the state is C(absent), the account will either not exist or be
|
||||
deactivated."
|
||||
- "If the state is C(changed_key), the account must exist. The account
|
||||
key will be changed; no other information will be touched."
|
||||
type: str
|
||||
required: true
|
||||
choices:
|
||||
- present
|
||||
- absent
|
||||
- changed_key
|
||||
allow_creation:
|
||||
description:
|
||||
- "Whether account creation is allowed (when state is C(present))."
|
||||
type: bool
|
||||
default: yes
|
||||
contact:
|
||||
description:
|
||||
- "A list of contact URLs."
|
||||
- "Email addresses must be prefixed with C(mailto:)."
|
||||
- "See U(https://tools.ietf.org/html/rfc8555#section-7.3)
|
||||
for what is allowed."
|
||||
- "Must be specified when state is C(present). Will be ignored
|
||||
if state is C(absent) or C(changed_key)."
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
terms_agreed:
|
||||
description:
|
||||
- "Boolean indicating whether you agree to the terms of service document."
|
||||
- "ACME servers can require this to be true."
|
||||
type: bool
|
||||
default: no
|
||||
new_account_key_src:
|
||||
description:
|
||||
- "Path to a file containing the ACME account RSA or Elliptic Curve key to change to."
|
||||
- "Same restrictions apply as to C(account_key_src)."
|
||||
- "Mutually exclusive with C(new_account_key_content)."
|
||||
- "Required if C(new_account_key_content) is not used and state is C(changed_key)."
|
||||
type: path
|
||||
new_account_key_content:
|
||||
description:
|
||||
- "Content of the ACME account RSA or Elliptic Curve key to change to."
|
||||
- "Same restrictions apply as to C(account_key_content)."
|
||||
- "Mutually exclusive with C(new_account_key_src)."
|
||||
- "Required if C(new_account_key_src) is not used and state is C(changed_key)."
|
||||
type: str
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Make sure account exists and has given contacts. We agree to TOS.
|
||||
acme_account:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
state: present
|
||||
terms_agreed: yes
|
||||
contact:
|
||||
- mailto:me@example.com
|
||||
- mailto:myself@example.org
|
||||
|
||||
- name: Make sure account has given email address. Don't create account if it doesn't exist
|
||||
acme_account:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
state: present
|
||||
allow_creation: no
|
||||
contact:
|
||||
- mailto:me@example.com
|
||||
|
||||
- name: Change account's key to the one stored in the variable new_account_key
|
||||
acme_account:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
new_account_key_content: '{{ new_account_key }}'
|
||||
state: changed_key
|
||||
|
||||
- name: Delete account (we have to use the new key)
|
||||
acme_account:
|
||||
account_key_content: '{{ new_account_key }}'
|
||||
state: absent
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
account_uri:
|
||||
description: ACME account URI, or None if account does not exist.
|
||||
returned: always
|
||||
type: str
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
handle_standard_module_arguments,
|
||||
get_default_argspec,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = get_default_argspec()
|
||||
argument_spec.update(dict(
|
||||
terms_agreed=dict(type='bool', default=False),
|
||||
state=dict(type='str', required=True, choices=['absent', 'present', 'changed_key']),
|
||||
allow_creation=dict(type='bool', default=True),
|
||||
contact=dict(type='list', elements='str', default=[]),
|
||||
new_account_key_src=dict(type='path'),
|
||||
new_account_key_content=dict(type='str', no_log=True),
|
||||
))
|
||||
module = AnsibleModule(
|
||||
argument_spec=argument_spec,
|
||||
required_one_of=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
['new_account_key_src', 'new_account_key_content'],
|
||||
),
|
||||
required_if=(
|
||||
# Make sure that for state == changed_key, one of
|
||||
# new_account_key_src and new_account_key_content are specified
|
||||
['state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
handle_standard_module_arguments(module, needs_acme_v2=True)
|
||||
|
||||
try:
|
||||
account = ACMEAccount(module)
|
||||
changed = False
|
||||
state = module.params.get('state')
|
||||
diff_before = {}
|
||||
diff_after = {}
|
||||
if state == 'absent':
|
||||
created, account_data = account.setup_account(allow_creation=False)
|
||||
if account_data:
|
||||
diff_before = dict(account_data)
|
||||
diff_before['public_account_key'] = account.key_data['jwk']
|
||||
if created:
|
||||
raise AssertionError('Unwanted account creation')
|
||||
if account_data is not None:
|
||||
# Account is not yet deactivated
|
||||
if not module.check_mode:
|
||||
# Deactivate it
|
||||
payload = {
|
||||
'status': 'deactivated'
|
||||
}
|
||||
result, info = account.send_signed_request(account.uri, payload)
|
||||
if info['status'] != 200:
|
||||
raise ModuleFailException('Error deactivating account: {0} {1}'.format(info['status'], result))
|
||||
changed = True
|
||||
elif state == 'present':
|
||||
allow_creation = module.params.get('allow_creation')
|
||||
# Make sure contact is a list of strings (unfortunately, Ansible doesn't do that for us)
|
||||
contact = [str(v) for v in module.params.get('contact')]
|
||||
terms_agreed = module.params.get('terms_agreed')
|
||||
created, account_data = account.setup_account(
|
||||
contact,
|
||||
terms_agreed=terms_agreed,
|
||||
allow_creation=allow_creation,
|
||||
)
|
||||
if account_data is None:
|
||||
raise ModuleFailException(msg='Account does not exist or is deactivated.')
|
||||
if created:
|
||||
diff_before = {}
|
||||
else:
|
||||
diff_before = dict(account_data)
|
||||
diff_before['public_account_key'] = 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']
|
||||
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)
|
||||
# Verify that the account exists and has not been deactivated
|
||||
created, account_data = account.setup_account(allow_creation=False)
|
||||
if created:
|
||||
raise AssertionError('Unwanted account creation')
|
||||
if account_data is None:
|
||||
raise ModuleFailException(msg='Account does not exist or is deactivated.')
|
||||
diff_before = dict(account_data)
|
||||
diff_before['public_account_key'] = 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']
|
||||
protected = {
|
||||
"alg": new_key_data['alg'],
|
||||
"jwk": new_key_data['jwk'],
|
||||
"url": url,
|
||||
}
|
||||
payload = {
|
||||
"account": account.uri,
|
||||
"newKey": new_key_data['jwk'], # specified in draft 12 and older
|
||||
"oldKey": account.jwk, # specified in draft 13 and newer
|
||||
}
|
||||
data = account.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))
|
||||
if module._diff:
|
||||
account.key_data = new_key_data
|
||||
account.jws_header['alg'] = new_key_data['alg']
|
||||
diff_after = account.get_account_data()
|
||||
elif module._diff:
|
||||
# Kind of fake diff_after
|
||||
diff_after = dict(diff_before)
|
||||
diff_after['public_account_key'] = new_key_data['jwk']
|
||||
changed = True
|
||||
result = {
|
||||
'changed': changed,
|
||||
'account_uri': account.uri,
|
||||
}
|
||||
if module._diff:
|
||||
result['diff'] = {
|
||||
'before': diff_before,
|
||||
'after': diff_after,
|
||||
}
|
||||
module.exit_json(**result)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1
plugins/modules/acme_account_facts.py
Symbolic link
1
plugins/modules/acme_account_facts.py
Symbolic link
@@ -0,0 +1 @@
|
||||
acme_account_info.py
|
||||
300
plugins/modules/acme_account_info.py
Normal file
300
plugins/modules/acme_account_info.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2018 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
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: acme_account_info
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
short_description: Retrieves information on ACME accounts
|
||||
description:
|
||||
- "Allows to retrieve information on accounts a CA supporting the
|
||||
L(ACME protocol,https://tools.ietf.org/html/rfc8555),
|
||||
such as L(Let's Encrypt,https://letsencrypt.org/)."
|
||||
- "This module only works with the ACME v2 protocol."
|
||||
notes:
|
||||
- "The M(acme_account) module allows to modify, create and delete ACME accounts."
|
||||
- "This module was called C(acme_account_facts) before Ansible 2.8. The usage
|
||||
did not change."
|
||||
options:
|
||||
retrieve_orders:
|
||||
description:
|
||||
- "Whether to retrieve the list of order URLs or order objects, if provided
|
||||
by the ACME server."
|
||||
- "A value of C(ignore) will not fetch the list of orders."
|
||||
- "Currently, Let's Encrypt does not return orders, so the C(orders) result
|
||||
will always be empty."
|
||||
type: str
|
||||
choices:
|
||||
- ignore
|
||||
- url_list
|
||||
- object_list
|
||||
default: ignore
|
||||
seealso:
|
||||
- module: acme_account
|
||||
description: Allows to create, modify or delete an ACME account.
|
||||
extends_documentation_fragment:
|
||||
- community.crypto.acme
|
||||
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Check whether an account with the given account key exists
|
||||
acme_account_info:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
register: account_data
|
||||
- name: Verify that account exists
|
||||
assert:
|
||||
that:
|
||||
- account_data.exists
|
||||
- name: Print account URI
|
||||
debug: var=account_data.account_uri
|
||||
- name: Print account contacts
|
||||
debug: var=account_data.account.contact
|
||||
|
||||
- name: Check whether the account exists and is accessible with the given account key
|
||||
acme_account_info:
|
||||
account_key_content: "{{ acme_account_key }}"
|
||||
account_uri: "{{ acme_account_uri }}"
|
||||
register: account_data
|
||||
- name: Verify that account exists
|
||||
assert:
|
||||
that:
|
||||
- account_data.exists
|
||||
- name: Print account contacts
|
||||
debug: var=account_data.account.contact
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
exists:
|
||||
description: Whether the account exists.
|
||||
returned: always
|
||||
type: bool
|
||||
|
||||
account_uri:
|
||||
description: ACME account URI, or None if account does not exist.
|
||||
returned: always
|
||||
type: str
|
||||
|
||||
account:
|
||||
description: The account information, as retrieved from the ACME server.
|
||||
returned: if account exists
|
||||
type: dict
|
||||
contains:
|
||||
contact:
|
||||
description: the challenge resource that must be created for validation
|
||||
returned: always
|
||||
type: list
|
||||
elements: str
|
||||
sample: "['mailto:me@example.com', 'tel:00123456789']"
|
||||
status:
|
||||
description: the account's status
|
||||
returned: always
|
||||
type: str
|
||||
choices: ['valid', 'deactivated', 'revoked']
|
||||
sample: valid
|
||||
orders:
|
||||
description:
|
||||
- A URL where a list of orders can be retrieved for this account.
|
||||
- Use the I(retrieve_orders) option to query this URL and retrieve the
|
||||
complete list of orders.
|
||||
returned: always
|
||||
type: str
|
||||
sample: https://example.ca/account/1/orders
|
||||
public_account_key:
|
||||
description: the public account key as a L(JSON Web Key,https://tools.ietf.org/html/rfc7517).
|
||||
returned: always
|
||||
type: str
|
||||
sample: '{"kty":"EC","crv":"P-256","x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4","y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"}'
|
||||
|
||||
orders:
|
||||
description:
|
||||
- "The list of orders."
|
||||
- "If I(retrieve_orders) is C(url_list), this will be a list of URLs."
|
||||
- "If I(retrieve_orders) is C(object_list), this will be a list of objects."
|
||||
type: list
|
||||
#elements: ... depends on retrieve_orders
|
||||
returned: if account exists, I(retrieve_orders) is not C(ignore), and server supports order listing
|
||||
contains:
|
||||
status:
|
||||
description: The order's status.
|
||||
type: str
|
||||
choices:
|
||||
- pending
|
||||
- ready
|
||||
- processing
|
||||
- valid
|
||||
- invalid
|
||||
expires:
|
||||
description:
|
||||
- When the order expires.
|
||||
- Timestamp should be formatted as described in RFC3339.
|
||||
- Only required to be included in result when I(status) is C(pending) or C(valid).
|
||||
type: str
|
||||
returned: when server gives expiry date
|
||||
identifiers:
|
||||
description:
|
||||
- List of identifiers this order is for.
|
||||
type: list
|
||||
elements: dict
|
||||
contains:
|
||||
type:
|
||||
description: Type of identifier. C(dns) or C(ip).
|
||||
type: str
|
||||
value:
|
||||
description: Name of identifier. Hostname or IP address.
|
||||
type: str
|
||||
wildcard:
|
||||
description: "Whether I(value) is actually a wildcard. The wildcard
|
||||
prefix C(*.) is not included in I(value) if this is C(true)."
|
||||
type: bool
|
||||
returned: required to be included if the identifier is wildcarded
|
||||
notBefore:
|
||||
description:
|
||||
- The requested value of the C(notBefore) field in the certificate.
|
||||
- Date should be formatted as described in RFC3339.
|
||||
- Server is not required to return this.
|
||||
type: str
|
||||
returned: when server returns this
|
||||
notAfter:
|
||||
description:
|
||||
- The requested value of the C(notAfter) field in the certificate.
|
||||
- Date should be formatted as described in RFC3339.
|
||||
- Server is not required to return this.
|
||||
type: str
|
||||
returned: when server returns this
|
||||
error:
|
||||
description:
|
||||
- In case an error occurred during processing, this contains information about the error.
|
||||
- The field is structured as a problem document (RFC7807).
|
||||
type: dict
|
||||
returned: when an error occurred
|
||||
authorizations:
|
||||
description:
|
||||
- A list of URLs for authorizations for this order.
|
||||
type: list
|
||||
elements: str
|
||||
finalize:
|
||||
description:
|
||||
- A URL used for finalizing an ACME order.
|
||||
type: str
|
||||
certificate:
|
||||
description:
|
||||
- The URL for retrieving the certificate.
|
||||
type: str
|
||||
returned: when certificate was issued
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
handle_standard_module_arguments,
|
||||
process_links,
|
||||
get_default_argspec,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def get_orders_list(module, account, 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)
|
||||
if not res.get('orders'):
|
||||
if orders:
|
||||
module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url))
|
||||
break
|
||||
# Add order URLs to result list
|
||||
orders.extend(res['orders'])
|
||||
# Extract URL of next part of results list
|
||||
new_orders_url = []
|
||||
|
||||
def f(link, relation):
|
||||
if relation == 'next':
|
||||
new_orders_url.append(link)
|
||||
|
||||
process_links(info, f)
|
||||
new_orders_url.append(None)
|
||||
previous_orders_url, orders_url = orders_url, new_orders_url.pop(0)
|
||||
if orders_url == previous_orders_url:
|
||||
# Prevent infinite loop
|
||||
orders_url = None
|
||||
return orders
|
||||
|
||||
|
||||
def get_order(account, order_url):
|
||||
'''
|
||||
Retrieve order data.
|
||||
'''
|
||||
return account.get_request(order_url, parse_json_result=True, fail_on_error=True)[0]
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = get_default_argspec()
|
||||
argument_spec.update(dict(
|
||||
retrieve_orders=dict(type='str', default='ignore', choices=['ignore', 'url_list', 'object_list']),
|
||||
))
|
||||
module = AnsibleModule(
|
||||
argument_spec=argument_spec,
|
||||
required_one_of=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
if module._name == 'acme_account_facts':
|
||||
module.deprecate("The 'acme_account_facts' module has been renamed to 'acme_account_info'", version='2.12')
|
||||
handle_standard_module_arguments(module, needs_acme_v2=True)
|
||||
|
||||
try:
|
||||
account = ACMEAccount(module)
|
||||
# Check whether account exists
|
||||
created, account_data = account.setup_account(
|
||||
[],
|
||||
allow_creation=False,
|
||||
remove_account_uri_if_not_exists=True,
|
||||
)
|
||||
if created:
|
||||
raise AssertionError('Unwanted account creation')
|
||||
result = {
|
||||
'changed': False,
|
||||
'exists': account.uri is not None,
|
||||
'account_uri': account.uri,
|
||||
}
|
||||
if 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']
|
||||
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'])
|
||||
if module.params['retrieve_orders'] == 'url_list':
|
||||
result['orders'] = orders
|
||||
else:
|
||||
result['orders'] = [get_order(account, order) for order in orders]
|
||||
module.exit_json(**result)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1257
plugins/modules/acme_certificate.py
Normal file
1257
plugins/modules/acme_certificate.py
Normal file
File diff suppressed because it is too large
Load Diff
223
plugins/modules/acme_certificate_revoke.py
Normal file
223
plugins/modules/acme_certificate_revoke.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: acme_certificate_revoke
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
short_description: Revoke certificates with the ACME protocol
|
||||
description:
|
||||
- "Allows to revoke certificates issued by a CA supporting the
|
||||
L(ACME protocol,https://tools.ietf.org/html/rfc8555),
|
||||
such as L(Let's Encrypt,https://letsencrypt.org/)."
|
||||
notes:
|
||||
- "Exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
- "Trying to revoke an already revoked certificate
|
||||
should result in an unchanged status, even if the revocation reason
|
||||
was different than the one specified here. Also, depending on the
|
||||
server, it can happen that some other error is returned if the
|
||||
certificate has already been revoked."
|
||||
seealso:
|
||||
- name: The Let's Encrypt documentation
|
||||
description: Documentation for the Let's Encrypt Certification Authority.
|
||||
Provides useful information for example on rate limits.
|
||||
link: https://letsencrypt.org/docs/
|
||||
- name: Automatic Certificate Management Environment (ACME)
|
||||
description: The specification of the ACME protocol (RFC 8555).
|
||||
link: https://tools.ietf.org/html/rfc8555
|
||||
- module: acme_inspect
|
||||
description: Allows to debug problems.
|
||||
extends_documentation_fragment:
|
||||
- community.crypto.acme
|
||||
|
||||
options:
|
||||
certificate:
|
||||
description:
|
||||
- "Path to the certificate to revoke."
|
||||
type: path
|
||||
required: yes
|
||||
account_key_src:
|
||||
description:
|
||||
- "Path to a file containing the ACME account RSA or Elliptic Curve
|
||||
key."
|
||||
- "RSA keys can be created with C(openssl rsa ...). Elliptic curve keys can
|
||||
be created with C(openssl ecparam -genkey ...). Any other tool creating
|
||||
private keys in PEM format can be used as well."
|
||||
- "Mutually exclusive with C(account_key_content)."
|
||||
- "Required if C(account_key_content) is not used."
|
||||
type: path
|
||||
account_key_content:
|
||||
description:
|
||||
- "Content of the ACME account RSA or Elliptic Curve key."
|
||||
- "Note that exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
- "I(Warning): the content will be written into a temporary file, which will
|
||||
be deleted by Ansible when the module completes. Since this is an
|
||||
important private key — it can be used to change the account key,
|
||||
or to revoke your certificates without knowing their private keys
|
||||
—, this might not be acceptable."
|
||||
- "In case C(cryptography) is used, the content is not written into a
|
||||
temporary file. It can still happen that it is written to disk by
|
||||
Ansible in the process of moving the module with its argument to
|
||||
the node where it is executed."
|
||||
type: str
|
||||
private_key_src:
|
||||
description:
|
||||
- "Path to the certificate's private key."
|
||||
- "Note that exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
type: path
|
||||
private_key_content:
|
||||
description:
|
||||
- "Content of the certificate's private key."
|
||||
- "Note that exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
- "I(Warning): the content will be written into a temporary file, which will
|
||||
be deleted by Ansible when the module completes. Since this is an
|
||||
important private key — it can be used to change the account key,
|
||||
or to revoke your certificates without knowing their private keys
|
||||
—, this might not be acceptable."
|
||||
- "In case C(cryptography) is used, the content is not written into a
|
||||
temporary file. It can still happen that it is written to disk by
|
||||
Ansible in the process of moving the module with its argument to
|
||||
the node where it is executed."
|
||||
type: str
|
||||
revoke_reason:
|
||||
description:
|
||||
- "One of the revocation reasonCodes defined in
|
||||
L(Section 5.3.1 of RFC5280,https://tools.ietf.org/html/rfc5280#section-5.3.1)."
|
||||
- "Possible values are C(0) (unspecified), C(1) (keyCompromise),
|
||||
C(2) (cACompromise), C(3) (affiliationChanged), C(4) (superseded),
|
||||
C(5) (cessationOfOperation), C(6) (certificateHold),
|
||||
C(8) (removeFromCRL), C(9) (privilegeWithdrawn),
|
||||
C(10) (aACompromise)"
|
||||
type: int
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Revoke certificate with account key
|
||||
acme_certificate_revoke:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
certificate: /etc/httpd/ssl/sample.com.crt
|
||||
|
||||
- name: Revoke certificate with certificate's private key
|
||||
acme_certificate_revoke:
|
||||
private_key_src: /etc/httpd/ssl/sample.com.key
|
||||
certificate: /etc/httpd/ssl/sample.com.crt
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
handle_standard_module_arguments,
|
||||
get_default_argspec,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = get_default_argspec()
|
||||
argument_spec.update(dict(
|
||||
private_key_src=dict(type='path'),
|
||||
private_key_content=dict(type='str', no_log=True),
|
||||
certificate=dict(type='path', required=True),
|
||||
revoke_reason=dict(type='int'),
|
||||
))
|
||||
module = AnsibleModule(
|
||||
argument_spec=argument_spec,
|
||||
required_one_of=(
|
||||
['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
|
||||
),
|
||||
supports_check_mode=False,
|
||||
)
|
||||
handle_standard_module_arguments(module)
|
||||
|
||||
try:
|
||||
account = ACMEAccount(module)
|
||||
# Load certificate
|
||||
certificate = pem_to_der(module.params.get('certificate'))
|
||||
certificate = nopad_b64(certificate)
|
||||
# Construct payload
|
||||
payload = {
|
||||
'certificate': certificate
|
||||
}
|
||||
if module.params.get('revoke_reason') is not None:
|
||||
payload['reason'] = module.params.get('revoke_reason')
|
||||
# Determine endpoint
|
||||
if module.params.get('acme_version') == 1:
|
||||
endpoint = account.directory['revoke-cert']
|
||||
payload['resource'] = 'revoke-cert'
|
||||
else:
|
||||
endpoint = account.directory['revokeCert']
|
||||
# Get hold of private key (if available) and make sure it comes from disk
|
||||
private_key = module.params.get('private_key_src')
|
||||
private_key_content = module.params.get('private_key_content')
|
||||
# Revoke certificate
|
||||
if private_key or private_key_content:
|
||||
# Step 1: load and parse private key
|
||||
error, private_key_data = account.parse_key(private_key, private_key_content)
|
||||
if error:
|
||||
raise ModuleFailException("error while parsing private key: %s" % error)
|
||||
# Step 2: sign revokation request with private key
|
||||
jws_header = {
|
||||
"alg": private_key_data['alg'],
|
||||
"jwk": private_key_data['jwk'],
|
||||
}
|
||||
result, info = account.send_signed_request(endpoint, payload, key_data=private_key_data, jws_header=jws_header)
|
||||
else:
|
||||
# Step 1: get hold of account URI
|
||||
created, account_data = account.setup_account(allow_creation=False)
|
||||
if created:
|
||||
raise AssertionError('Unwanted account creation')
|
||||
if account_data is None:
|
||||
raise ModuleFailException(msg='Account does not exist or is deactivated.')
|
||||
# Step 2: sign revokation request with account key
|
||||
result, info = account.send_signed_request(endpoint, payload)
|
||||
if info['status'] != 200:
|
||||
already_revoked = False
|
||||
# Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6)
|
||||
if result.get('type') == 'urn:ietf:params:acme:error:alreadyRevoked':
|
||||
already_revoked = True
|
||||
else:
|
||||
# Hack for Boulder errors
|
||||
if module.params.get('acme_version') == 1:
|
||||
error_type = 'urn:acme:error:malformed'
|
||||
else:
|
||||
error_type = 'urn:ietf:params:acme:error:malformed'
|
||||
if result.get('type') == error_type and result.get('detail') == 'Certificate already revoked':
|
||||
# Fallback: boulder returns this in case the certificate was already revoked.
|
||||
already_revoked = True
|
||||
# If we know the certificate was already revoked, we don't fail,
|
||||
# but successfully terminate while indicating no change
|
||||
if already_revoked:
|
||||
module.exit_json(changed=False)
|
||||
raise ModuleFailException('Error revoking certificate: {0} {1}'.format(info['status'], result))
|
||||
module.exit_json(changed=True)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
298
plugins/modules/acme_challenge_cert_helper.py
Normal file
298
plugins/modules/acme_challenge_cert_helper.py
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2018 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
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: acme_challenge_cert_helper
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
short_description: Prepare certificates required for ACME challenges such as C(tls-alpn-01)
|
||||
description:
|
||||
- "Prepares certificates for ACME challenges such as C(tls-alpn-01)."
|
||||
- "The raw data is provided by the M(acme_certificate) module, and needs to be
|
||||
converted to a certificate to be used for challenge validation. This module
|
||||
provides a simple way to generate the required certificates."
|
||||
seealso:
|
||||
- name: Automatic Certificate Management Environment (ACME)
|
||||
description: The specification of the ACME protocol (RFC 8555).
|
||||
link: https://tools.ietf.org/html/rfc8555
|
||||
- name: ACME TLS ALPN Challenge Extension
|
||||
description: The specification of the C(tls-alpn-01) challenge (RFC 8737).
|
||||
link: https://www.rfc-editor.org/rfc/rfc8737.html
|
||||
requirements:
|
||||
- "cryptography >= 1.3"
|
||||
options:
|
||||
challenge:
|
||||
description:
|
||||
- "The challenge type."
|
||||
type: str
|
||||
required: yes
|
||||
choices:
|
||||
- tls-alpn-01
|
||||
challenge_data:
|
||||
description:
|
||||
- "The C(challenge_data) entry provided by M(acme_certificate) for the challenge."
|
||||
type: dict
|
||||
required: yes
|
||||
private_key_src:
|
||||
description:
|
||||
- "Path to a file containing the private key file to use for this challenge
|
||||
certificate."
|
||||
- "Mutually exclusive with C(private_key_content)."
|
||||
type: path
|
||||
private_key_content:
|
||||
description:
|
||||
- "Content of the private key to use for this challenge certificate."
|
||||
- "Mutually exclusive with C(private_key_src)."
|
||||
type: str
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Create challenges for a given CRT for sample.com
|
||||
acme_certificate:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
challenge: tls-alpn-01
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
dest: /etc/httpd/ssl/sample.com.crt
|
||||
register: sample_com_challenge
|
||||
|
||||
- name: Create certificates for challenges
|
||||
acme_challenge_cert_helper:
|
||||
challenge: tls-alpn-01
|
||||
challenge_data: "{{ item.value['tls-alpn-01'] }}"
|
||||
private_key_src: /etc/pki/cert/key/sample.com.key
|
||||
loop: "{{ sample_com_challenge.challenge_data | dictsort }}"
|
||||
register: sample_com_challenge_certs
|
||||
|
||||
- name: Install challenge certificates
|
||||
# We need to set up HTTPS such that for the domain,
|
||||
# regular_certificate is delivered for regular connections,
|
||||
# except if ALPN selects the "acme-tls/1"; then, the
|
||||
# challenge_certificate must be delivered.
|
||||
# This can for example be achieved with very new versions
|
||||
# of NGINX; search for ssl_preread and
|
||||
# ssl_preread_alpn_protocols for information on how to
|
||||
# route by ALPN protocol.
|
||||
...:
|
||||
domain: "{{ item.domain }}"
|
||||
challenge_certificate: "{{ item.challenge_certificate }}"
|
||||
regular_certificate: "{{ item.regular_certificate }}"
|
||||
private_key: /etc/pki/cert/key/sample.com.key
|
||||
loop: "{{ sample_com_challenge_certs.results }}"
|
||||
|
||||
- name: Create certificate for a given CSR for sample.com
|
||||
acme_certificate:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
challenge: tls-alpn-01
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
dest: /etc/httpd/ssl/sample.com.crt
|
||||
data: "{{ sample_com_challenge }}"
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
domain:
|
||||
description:
|
||||
- "The domain the challenge is for. The certificate should be provided if
|
||||
this is specified in the request's the C(Host) header."
|
||||
returned: always
|
||||
type: str
|
||||
identifier_type:
|
||||
description:
|
||||
- "The identifier type for the actual resource identifier. Will be C(dns)
|
||||
or C(ip)."
|
||||
returned: always
|
||||
type: str
|
||||
version_added: "2.8"
|
||||
identifier:
|
||||
description:
|
||||
- "The identifier for the actual resource. Will be a domain name if the
|
||||
type is C(dns), or an IP address if the type is C(ip)."
|
||||
returned: always
|
||||
type: str
|
||||
version_added: "2.8"
|
||||
challenge_certificate:
|
||||
description:
|
||||
- "The challenge certificate in PEM format."
|
||||
returned: always
|
||||
type: str
|
||||
regular_certificate:
|
||||
description:
|
||||
- "A self-signed certificate for the challenge domain."
|
||||
- "If no existing certificate exists, can be used to set-up
|
||||
https in the first place if that is needed for providing
|
||||
the challenge."
|
||||
returned: always
|
||||
type: str
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
read_file,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_bytes, to_text
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.hazmat.primitives.serialization
|
||||
import cryptography.hazmat.primitives.asymmetric.rsa
|
||||
import cryptography.hazmat.primitives.asymmetric.ec
|
||||
import cryptography.hazmat.primitives.asymmetric.padding
|
||||
import cryptography.hazmat.primitives.hashes
|
||||
import cryptography.hazmat.primitives.asymmetric.utils
|
||||
import cryptography.x509
|
||||
import cryptography.x509.oid
|
||||
import ipaddress
|
||||
from distutils.version import LooseVersion
|
||||
HAS_CRYPTOGRAPHY = (LooseVersion(cryptography.__version__) >= LooseVersion('1.3'))
|
||||
_cryptography_backend = cryptography.hazmat.backends.default_backend()
|
||||
except ImportError as dummy:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
HAS_CRYPTOGRAPHY = False
|
||||
|
||||
|
||||
# Convert byte string to ASN1 encoded octet string
|
||||
if sys.version_info[0] >= 3:
|
||||
def encode_octet_string(octet_string):
|
||||
if len(octet_string) >= 128:
|
||||
raise ModuleFailException('Cannot handle octet strings with more than 128 bytes')
|
||||
return bytes([0x4, len(octet_string)]) + octet_string
|
||||
else:
|
||||
def encode_octet_string(octet_string):
|
||||
if len(octet_string) >= 128:
|
||||
raise ModuleFailException('Cannot handle octet strings with more than 128 bytes')
|
||||
return b'\x04' + chr(len(octet_string)) + octet_string
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
challenge=dict(type='str', required=True, choices=['tls-alpn-01']),
|
||||
challenge_data=dict(type='dict', required=True),
|
||||
private_key_src=dict(type='path'),
|
||||
private_key_content=dict(type='str', no_log=True),
|
||||
),
|
||||
required_one_of=(
|
||||
['private_key_src', 'private_key_content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['private_key_src', 'private_key_content'],
|
||||
),
|
||||
)
|
||||
if not HAS_CRYPTOGRAPHY:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= 1.3'), exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
try:
|
||||
# Get parameters
|
||||
challenge = module.params['challenge']
|
||||
challenge_data = module.params['challenge_data']
|
||||
|
||||
# Get hold of private key
|
||||
private_key_content = module.params.get('private_key_content')
|
||||
if private_key_content is None:
|
||||
private_key_content = read_file(module.params['private_key_src'])
|
||||
else:
|
||||
private_key_content = to_bytes(private_key_content)
|
||||
try:
|
||||
private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key(private_key_content, password=None, backend=_cryptography_backend)
|
||||
except Exception as e:
|
||||
raise ModuleFailException('Error while loading private key: {0}'.format(e))
|
||||
|
||||
# Some common attributes
|
||||
domain = to_text(challenge_data['resource'])
|
||||
identifier_type, identifier = to_text(challenge_data.get('resource_original', 'dns:' + challenge_data['resource'])).split(':', 1)
|
||||
subject = issuer = cryptography.x509.Name([])
|
||||
not_valid_before = datetime.datetime.utcnow()
|
||||
not_valid_after = datetime.datetime.utcnow() + datetime.timedelta(days=10)
|
||||
if identifier_type == 'dns':
|
||||
san = cryptography.x509.DNSName(identifier)
|
||||
elif identifier_type == 'ip':
|
||||
san = cryptography.x509.IPAddress(ipaddress.ip_address(identifier))
|
||||
else:
|
||||
raise ModuleFailException('Unsupported identifier type "{0}"'.format(identifier_type))
|
||||
|
||||
# Generate regular self-signed certificate
|
||||
regular_certificate = cryptography.x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
issuer
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
cryptography.x509.random_serial_number()
|
||||
).not_valid_before(
|
||||
not_valid_before
|
||||
).not_valid_after(
|
||||
not_valid_after
|
||||
).add_extension(
|
||||
cryptography.x509.SubjectAlternativeName([san]),
|
||||
critical=False,
|
||||
).sign(
|
||||
private_key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
_cryptography_backend
|
||||
)
|
||||
|
||||
# Process challenge
|
||||
if challenge == 'tls-alpn-01':
|
||||
value = base64.b64decode(challenge_data['resource_value'])
|
||||
challenge_certificate = cryptography.x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
issuer
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
cryptography.x509.random_serial_number()
|
||||
).not_valid_before(
|
||||
not_valid_before
|
||||
).not_valid_after(
|
||||
not_valid_after
|
||||
).add_extension(
|
||||
cryptography.x509.SubjectAlternativeName([san]),
|
||||
critical=False,
|
||||
).add_extension(
|
||||
cryptography.x509.UnrecognizedExtension(
|
||||
cryptography.x509.ObjectIdentifier("1.3.6.1.5.5.7.1.31"),
|
||||
encode_octet_string(value),
|
||||
),
|
||||
critical=True,
|
||||
).sign(
|
||||
private_key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
_cryptography_backend
|
||||
)
|
||||
|
||||
module.exit_json(
|
||||
changed=True,
|
||||
domain=domain,
|
||||
identifier_type=identifier_type,
|
||||
identifier=identifier,
|
||||
challenge_certificate=challenge_certificate.public_bytes(cryptography.hazmat.primitives.serialization.Encoding.PEM),
|
||||
regular_certificate=regular_certificate.public_bytes(cryptography.hazmat.primitives.serialization.Encoding.PEM)
|
||||
)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
320
plugins/modules/acme_inspect.py
Normal file
320
plugins/modules/acme_inspect.py
Normal file
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2018 Felix Fontein (@felixfontein)
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: acme_inspect
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
short_description: Send direct requests to an ACME server
|
||||
description:
|
||||
- "Allows to send direct requests to an ACME server with the
|
||||
L(ACME protocol,https://tools.ietf.org/html/rfc8555),
|
||||
which is supported by CAs such as L(Let's Encrypt,https://letsencrypt.org/)."
|
||||
- "This module can be used to debug failed certificate request attempts,
|
||||
for example when M(acme_certificate) fails or encounters a problem which
|
||||
you wish to investigate."
|
||||
- "The module can also be used to directly access features of an ACME servers
|
||||
which are not yet supported by the Ansible ACME modules."
|
||||
notes:
|
||||
- "The I(account_uri) option must be specified for properly authenticated
|
||||
ACME v2 requests (except a C(new-account) request)."
|
||||
- "Using the C(ansible) tool, M(acme_inspect) can be used to directly execute
|
||||
ACME requests without the need of writing a playbook. For example, the
|
||||
following command retrieves the ACME account with ID 1 from Let's Encrypt
|
||||
(assuming C(/path/to/key) is the correct private account key):
|
||||
C(ansible localhost -m acme_inspect -a \"account_key_src=/path/to/key
|
||||
acme_directory=https://acme-v02.api.letsencrypt.org/directory acme_version=2
|
||||
account_uri=https://acme-v02.api.letsencrypt.org/acme/acct/1 method=get
|
||||
url=https://acme-v02.api.letsencrypt.org/acme/acct/1\")"
|
||||
seealso:
|
||||
- name: Automatic Certificate Management Environment (ACME)
|
||||
description: The specification of the ACME protocol (RFC 8555).
|
||||
link: https://tools.ietf.org/html/rfc8555
|
||||
- name: ACME TLS ALPN Challenge Extension
|
||||
description: The specification of the C(tls-alpn-01) challenge (RFC 8737).
|
||||
link: https://www.rfc-editor.org/rfc/rfc8737.html
|
||||
extends_documentation_fragment:
|
||||
- community.crypto.acme
|
||||
|
||||
options:
|
||||
url:
|
||||
description:
|
||||
- "The URL to send the request to."
|
||||
- "Must be specified if I(method) is not C(directory-only)."
|
||||
type: str
|
||||
method:
|
||||
description:
|
||||
- "The method to use to access the given URL on the ACME server."
|
||||
- "The value C(post) executes an authenticated POST request. The content
|
||||
must be specified in the I(content) option."
|
||||
- "The value C(get) executes an authenticated POST-as-GET request for ACME v2,
|
||||
and a regular GET request for ACME v1."
|
||||
- "The value C(directory-only) only retrieves the directory, without doing
|
||||
a request."
|
||||
type: str
|
||||
default: get
|
||||
choices:
|
||||
- get
|
||||
- post
|
||||
- directory-only
|
||||
content:
|
||||
description:
|
||||
- "An encoded JSON object which will be sent as the content if I(method)
|
||||
is C(post)."
|
||||
- "Required when I(method) is C(post), and not allowed otherwise."
|
||||
type: str
|
||||
fail_on_acme_error:
|
||||
description:
|
||||
- "If I(method) is C(post) or C(get), make the module fail in case an ACME
|
||||
error is returned."
|
||||
type: bool
|
||||
default: yes
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Get directory
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
method: directory-only
|
||||
register: directory
|
||||
|
||||
- name: Create an account
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
url: "{{ directory.newAccount}}"
|
||||
method: post
|
||||
content: '{"termsOfServiceAgreed":true}'
|
||||
register: account_creation
|
||||
# account_creation.headers.location contains the account URI
|
||||
# if creation was successful
|
||||
|
||||
- name: Get account information
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
url: "{{ account_creation.headers.location }}"
|
||||
method: get
|
||||
|
||||
- name: Update account contacts
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
url: "{{ account_creation.headers.location }}"
|
||||
method: post
|
||||
content: '{{ account_info | to_json }}'
|
||||
vars:
|
||||
account_info:
|
||||
# For valid values, see
|
||||
# https://tools.ietf.org/html/rfc8555#section-7.3
|
||||
contact:
|
||||
- mailto:me@example.com
|
||||
|
||||
- name: Create certificate order
|
||||
acme_certificate:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
|
||||
challenge: http-01
|
||||
register: certificate_request
|
||||
|
||||
# Assume something went wrong. certificate_request.order_uri contains
|
||||
# the order URI.
|
||||
|
||||
- name: Get order information
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
url: "{{ certificate_request.order_uri }}"
|
||||
method: get
|
||||
register: order
|
||||
|
||||
- name: Get first authz for order
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
url: "{{ order.output_json.authorizations[0] }}"
|
||||
method: get
|
||||
register: authz
|
||||
|
||||
- name: Get HTTP-01 challenge for authz
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
url: "{{ authz.output_json.challenges | selectattr('type', 'equalto', 'http-01') }}"
|
||||
method: get
|
||||
register: http01challenge
|
||||
|
||||
- name: Activate HTTP-01 challenge manually
|
||||
acme_inspect:
|
||||
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acme_version: 2
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_uri: "{{ account_creation.headers.location }}"
|
||||
url: "{{ http01challenge.url }}"
|
||||
method: post
|
||||
content: '{}'
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
directory:
|
||||
description: The ACME directory's content
|
||||
returned: always
|
||||
type: dict
|
||||
sample: |
|
||||
{
|
||||
"a85k3x9f91A4": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",
|
||||
"keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change",
|
||||
"meta": {
|
||||
"caaIdentities": [
|
||||
"letsencrypt.org"
|
||||
],
|
||||
"termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
|
||||
"website": "https://letsencrypt.org"
|
||||
},
|
||||
"newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct",
|
||||
"newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce",
|
||||
"newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
|
||||
"revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert"
|
||||
}
|
||||
headers:
|
||||
description: The request's HTTP headers (with lowercase keys)
|
||||
returned: always
|
||||
type: dict
|
||||
sample: |
|
||||
{
|
||||
"boulder-requester": "12345",
|
||||
"cache-control": "max-age=0, no-cache, no-store",
|
||||
"connection": "close",
|
||||
"content-length": "904",
|
||||
"content-type": "application/json",
|
||||
"cookies": {},
|
||||
"cookies_string": "",
|
||||
"date": "Wed, 07 Nov 2018 12:34:56 GMT",
|
||||
"expires": "Wed, 07 Nov 2018 12:44:56 GMT",
|
||||
"link": "<https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf>;rel=\"terms-of-service\"",
|
||||
"msg": "OK (904 bytes)",
|
||||
"pragma": "no-cache",
|
||||
"replay-nonce": "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGH",
|
||||
"server": "nginx",
|
||||
"status": 200,
|
||||
"strict-transport-security": "max-age=604800",
|
||||
"url": "https://acme-v02.api.letsencrypt.org/acme/acct/46161",
|
||||
"x-frame-options": "DENY"
|
||||
}
|
||||
output_text:
|
||||
description: The raw text output
|
||||
returned: always
|
||||
type: str
|
||||
sample: "{\\n \\\"id\\\": 12345,\\n \\\"key\\\": {\\n \\\"kty\\\": \\\"RSA\\\",\\n ..."
|
||||
output_json:
|
||||
description: The output parsed as JSON
|
||||
returned: if output can be parsed as JSON
|
||||
type: dict
|
||||
sample:
|
||||
- id: 12345
|
||||
- key:
|
||||
- kty: RSA
|
||||
- ...
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
ACMEAccount,
|
||||
handle_standard_module_arguments,
|
||||
get_default_argspec,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils._text import to_native, to_bytes
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = get_default_argspec()
|
||||
argument_spec.update(dict(
|
||||
url=dict(type='str'),
|
||||
method=dict(type='str', choices=['get', 'post', 'directory-only'], default='get'),
|
||||
content=dict(type='str'),
|
||||
fail_on_acme_error=dict(type='bool', default=True),
|
||||
))
|
||||
module = AnsibleModule(
|
||||
argument_spec=argument_spec,
|
||||
mutually_exclusive=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
),
|
||||
required_if=(
|
||||
['method', 'get', ['url']],
|
||||
['method', 'post', ['url', 'content']],
|
||||
['method', 'get', ['account_key_src', 'account_key_content'], True],
|
||||
['method', 'post', ['account_key_src', 'account_key_content'], True],
|
||||
),
|
||||
)
|
||||
handle_standard_module_arguments(module)
|
||||
|
||||
result = dict()
|
||||
changed = False
|
||||
try:
|
||||
# Get hold of ACMEAccount object (includes directory)
|
||||
account = ACMEAccount(module)
|
||||
method = module.params['method']
|
||||
result['directory'] = account.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)
|
||||
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)
|
||||
# Update results
|
||||
result.update(dict(
|
||||
headers=info,
|
||||
output_text=to_native(data),
|
||||
))
|
||||
# See if we can parse the result as JSON
|
||||
try:
|
||||
result['output_json'] = json.loads(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))
|
||||
# Done!
|
||||
module.exit_json(changed=changed, **result)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module, **result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
349
plugins/modules/certificate_complete_chain.py
Normal file
349
plugins/modules/certificate_complete_chain.py
Normal file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# (c) 2018, 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
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: certificate_complete_chain
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
short_description: Complete certificate chain given a set of untrusted and root certificates
|
||||
description:
|
||||
- "This module completes a given chain of certificates in PEM format by finding
|
||||
intermediate certificates from a given set of certificates, until it finds a root
|
||||
certificate in another given set of certificates."
|
||||
- "This can for example be used to find the root certificate for a certificate chain
|
||||
returned by M(acme_certificate)."
|
||||
- "Note that this module does I(not) check for validity of the chains. It only
|
||||
checks that issuer and subject match, and that the signature is correct. It
|
||||
ignores validity dates and key usage completely. If you need to verify that a
|
||||
generated chain is valid, please use C(openssl verify ...)."
|
||||
requirements:
|
||||
- "cryptography >= 1.5"
|
||||
options:
|
||||
input_chain:
|
||||
description:
|
||||
- A concatenated set of certificates in PEM format forming a chain.
|
||||
- The module will try to complete this chain.
|
||||
type: str
|
||||
required: yes
|
||||
root_certificates:
|
||||
description:
|
||||
- "A list of filenames or directories."
|
||||
- "A filename is assumed to point to a file containing one or more certificates
|
||||
in PEM format. All certificates in this file will be added to the set of
|
||||
root certificates."
|
||||
- "If a directory name is given, all files in the directory and its
|
||||
subdirectories will be scanned and tried to be parsed as concatenated
|
||||
certificates in PEM format."
|
||||
- "Symbolic links will be followed."
|
||||
type: list
|
||||
elements: path
|
||||
required: yes
|
||||
intermediate_certificates:
|
||||
description:
|
||||
- "A list of filenames or directories."
|
||||
- "A filename is assumed to point to a file containing one or more certificates
|
||||
in PEM format. All certificates in this file will be added to the set of
|
||||
root certificates."
|
||||
- "If a directory name is given, all files in the directory and its
|
||||
subdirectories will be scanned and tried to be parsed as concatenated
|
||||
certificates in PEM format."
|
||||
- "Symbolic links will be followed."
|
||||
type: list
|
||||
elements: path
|
||||
default: []
|
||||
'''
|
||||
|
||||
|
||||
EXAMPLES = '''
|
||||
# Given a leaf certificate for www.ansible.com and one or more intermediate
|
||||
# certificates, finds the associated root certificate.
|
||||
- name: Find root certificate
|
||||
certificate_complete_chain:
|
||||
input_chain: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com-fullchain.pem') }}"
|
||||
root_certificates:
|
||||
- /etc/ca-certificates/
|
||||
register: www_ansible_com
|
||||
- name: Write root certificate to disk
|
||||
copy:
|
||||
dest: /etc/ssl/csr/www.ansible.com-root.pem
|
||||
content: "{{ www_ansible_com.root }}"
|
||||
|
||||
# Given a leaf certificate for www.ansible.com, and a list of intermediate
|
||||
# certificates, finds the associated root certificate.
|
||||
- name: Find root certificate
|
||||
certificate_complete_chain:
|
||||
input_chain: "{{ lookup('file', '/etc/ssl/csr/www.ansible.com.pem') }}"
|
||||
intermediate_certificates:
|
||||
- /etc/ssl/csr/www.ansible.com-chain.pem
|
||||
root_certificates:
|
||||
- /etc/ca-certificates/
|
||||
register: www_ansible_com
|
||||
- name: Write complete chain to disk
|
||||
copy:
|
||||
dest: /etc/ssl/csr/www.ansible.com-completechain.pem
|
||||
content: "{{ ''.join(www_ansible_com.complete_chain) }}"
|
||||
- name: Write root chain (intermediates and root) to disk
|
||||
copy:
|
||||
dest: /etc/ssl/csr/www.ansible.com-rootchain.pem
|
||||
content: "{{ ''.join(www_ansible_com.chain) }}"
|
||||
'''
|
||||
|
||||
|
||||
RETURN = '''
|
||||
root:
|
||||
description:
|
||||
- "The root certificate in PEM format."
|
||||
returned: success
|
||||
type: str
|
||||
chain:
|
||||
description:
|
||||
- "The chain added to the given input chain. Includes the root certificate."
|
||||
- "Returned as a list of PEM certificates."
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
complete_chain:
|
||||
description:
|
||||
- "The completed chain, including leaf, all intermediates, and root."
|
||||
- "Returned as a list of PEM certificates."
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
'''
|
||||
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_bytes
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.hazmat.primitives.serialization
|
||||
import cryptography.hazmat.primitives.asymmetric.rsa
|
||||
import cryptography.hazmat.primitives.asymmetric.ec
|
||||
import cryptography.hazmat.primitives.asymmetric.padding
|
||||
import cryptography.hazmat.primitives.hashes
|
||||
import cryptography.hazmat.primitives.asymmetric.utils
|
||||
import cryptography.x509
|
||||
import cryptography.x509.oid
|
||||
from distutils.version import LooseVersion
|
||||
HAS_CRYPTOGRAPHY = (LooseVersion(cryptography.__version__) >= LooseVersion('1.5'))
|
||||
_cryptography_backend = cryptography.hazmat.backends.default_backend()
|
||||
except ImportError as dummy:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
HAS_CRYPTOGRAPHY = False
|
||||
|
||||
|
||||
class Certificate(object):
|
||||
'''
|
||||
Stores PEM with parsed certificate.
|
||||
'''
|
||||
def __init__(self, pem, cert):
|
||||
if not (pem.endswith('\n') or pem.endswith('\r')):
|
||||
pem = pem + '\n'
|
||||
self.pem = pem
|
||||
self.cert = cert
|
||||
|
||||
|
||||
def is_parent(module, cert, potential_parent):
|
||||
'''
|
||||
Tests whether the given certificate has been issued by the potential parent certificate.
|
||||
'''
|
||||
# Check issuer
|
||||
if cert.cert.issuer != potential_parent.cert.subject:
|
||||
return False
|
||||
# Check signature
|
||||
public_key = potential_parent.cert.public_key()
|
||||
try:
|
||||
if isinstance(public_key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey):
|
||||
public_key.verify(
|
||||
cert.cert.signature,
|
||||
cert.cert.tbs_certificate_bytes,
|
||||
cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15(),
|
||||
cert.cert.signature_hash_algorithm
|
||||
)
|
||||
elif isinstance(public_key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey):
|
||||
public_key.verify(
|
||||
cert.cert.signature,
|
||||
cert.cert.tbs_certificate_bytes,
|
||||
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cert.cert.signature_hash_algorithm),
|
||||
)
|
||||
else:
|
||||
# Unknown public key type
|
||||
module.warn('Unknown public key type "{0}"'.format(public_key))
|
||||
return False
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature as dummy:
|
||||
return False
|
||||
except Exception as e:
|
||||
module.fail_json(msg='Unknown error on signature validation: {0}'.format(e))
|
||||
|
||||
|
||||
def parse_PEM_list(module, text, source, fail_on_error=True):
|
||||
'''
|
||||
Parse concatenated PEM certificates. Return list of ``Certificate`` objects.
|
||||
'''
|
||||
result = []
|
||||
lines = text.splitlines(True)
|
||||
current = None
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
if line.startswith('-----BEGIN '):
|
||||
current = [line]
|
||||
elif current is not None:
|
||||
current.append(line)
|
||||
if line.startswith('-----END '):
|
||||
cert_pem = ''.join(current)
|
||||
current = None
|
||||
# Try to load PEM certificate
|
||||
try:
|
||||
cert = cryptography.x509.load_pem_x509_certificate(to_bytes(cert_pem), _cryptography_backend)
|
||||
result.append(Certificate(cert_pem, cert))
|
||||
except Exception as e:
|
||||
msg = 'Cannot parse certificate #{0} from {1}: {2}'.format(len(result) + 1, source, e)
|
||||
if fail_on_error:
|
||||
module.fail_json(msg=msg)
|
||||
else:
|
||||
module.warn(msg)
|
||||
return result
|
||||
|
||||
|
||||
def load_PEM_list(module, path, fail_on_error=True):
|
||||
'''
|
||||
Load concatenated PEM certificates from file. Return list of ``Certificate`` objects.
|
||||
'''
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return parse_PEM_list(module, f.read().decode('utf-8'), source=path, fail_on_error=fail_on_error)
|
||||
except Exception as e:
|
||||
msg = 'Cannot read certificate file {0}: {1}'.format(path, e)
|
||||
if fail_on_error:
|
||||
module.fail_json(msg=msg)
|
||||
else:
|
||||
module.warn(msg)
|
||||
return []
|
||||
|
||||
|
||||
class CertificateSet(object):
|
||||
'''
|
||||
Stores a set of certificates. Allows to search for parent (issuer of a certificate).
|
||||
'''
|
||||
|
||||
def __init__(self, module):
|
||||
self.module = module
|
||||
self.certificates = set()
|
||||
self.certificate_by_issuer = dict()
|
||||
|
||||
def _load_file(self, path):
|
||||
certs = load_PEM_list(self.module, path, fail_on_error=False)
|
||||
for cert in certs:
|
||||
self.certificates.add(cert)
|
||||
self.certificate_by_issuer[cert.cert.subject] = cert
|
||||
|
||||
def load(self, path):
|
||||
'''
|
||||
Load lists of PEM certificates from a file or a directory.
|
||||
'''
|
||||
b_path = to_bytes(path, errors='surrogate_or_strict')
|
||||
if os.path.isdir(b_path):
|
||||
for directory, dummy, files in os.walk(b_path, followlinks=True):
|
||||
for file in files:
|
||||
self._load_file(os.path.join(directory, file))
|
||||
else:
|
||||
self._load_file(b_path)
|
||||
|
||||
def find_parent(self, cert):
|
||||
'''
|
||||
Search for the parent (issuer) of a certificate. Return ``None`` if none was found.
|
||||
'''
|
||||
potential_parent = self.certificate_by_issuer.get(cert.cert.issuer)
|
||||
if potential_parent is not None:
|
||||
if is_parent(self.module, cert, potential_parent):
|
||||
return potential_parent
|
||||
return None
|
||||
|
||||
|
||||
def format_cert(cert):
|
||||
'''
|
||||
Return human readable representation of certificate for error messages.
|
||||
'''
|
||||
return str(cert.cert)
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
input_chain=dict(type='str', required=True),
|
||||
root_certificates=dict(type='list', required=True, elements='path'),
|
||||
intermediate_certificates=dict(type='list', default=[], elements='path'),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
if not HAS_CRYPTOGRAPHY:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= 1.5'), exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
# Load chain
|
||||
chain = parse_PEM_list(module, module.params['input_chain'], source='input chain')
|
||||
if len(chain) == 0:
|
||||
module.fail_json(msg='Input chain must contain at least one certificate')
|
||||
|
||||
# Check chain
|
||||
for i, parent in enumerate(chain):
|
||||
if i > 0:
|
||||
if not is_parent(module, chain[i - 1], parent):
|
||||
module.fail_json(msg=('Cannot verify input chain: certificate #{2}: {3} is not issuer ' +
|
||||
'of certificate #{0}: {1}').format(i, format_cert(chain[i - 1]), i + 1, format_cert(parent)))
|
||||
|
||||
# Load intermediate certificates
|
||||
intermediates = CertificateSet(module)
|
||||
for path in module.params['intermediate_certificates']:
|
||||
intermediates.load(path)
|
||||
|
||||
# Load root certificates
|
||||
roots = CertificateSet(module)
|
||||
for path in module.params['root_certificates']:
|
||||
roots.load(path)
|
||||
|
||||
# Try to complete chain
|
||||
current = chain[-1]
|
||||
completed = []
|
||||
while current:
|
||||
root = roots.find_parent(current)
|
||||
if root:
|
||||
completed.append(root)
|
||||
break
|
||||
intermediate = intermediates.find_parent(current)
|
||||
if intermediate:
|
||||
completed.append(intermediate)
|
||||
current = intermediate
|
||||
else:
|
||||
module.fail_json(msg='Cannot complete chain. Stuck at certificate {0}'.format(format_cert(current)))
|
||||
|
||||
# Return results
|
||||
complete_chain = chain + completed
|
||||
module.exit_json(
|
||||
changed=False,
|
||||
root=complete_chain[-1].pem,
|
||||
chain=[cert.pem for cert in completed],
|
||||
complete_chain=[cert.pem for cert in complete_chain],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
952
plugins/modules/ecs_certificate.py
Normal file
952
plugins/modules/ecs_certificate.py
Normal file
@@ -0,0 +1,952 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c), Entrust Datacard Corporation, 2019
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: ecs_certificate
|
||||
author:
|
||||
- Chris Trufan (@ctrufan)
|
||||
short_description: Request SSL/TLS certificates with the Entrust Certificate Services (ECS) API
|
||||
description:
|
||||
- Create, reissue, and renew certificates with the Entrust Certificate Services (ECS) API.
|
||||
- Requires credentials for the L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API.
|
||||
- In order to request a certificate, the domain and organization used in the certificate signing request must be already
|
||||
validated in the ECS system. It is I(not) the responsibility of this module to perform those steps.
|
||||
notes:
|
||||
- C(path) must be specified as the output location of the certificate.
|
||||
requirements:
|
||||
- cryptography >= 1.6
|
||||
options:
|
||||
backup:
|
||||
description:
|
||||
- Whether a backup should be made for the certificate in I(path).
|
||||
type: bool
|
||||
default: false
|
||||
force:
|
||||
description:
|
||||
- If force is used, a certificate is requested regardless of whether I(path) points to an existing valid certificate.
|
||||
- If C(request_type=renew), a forced renew will fail if the certificate being renewed has been issued within the past 30 days, regardless of the
|
||||
value of I(remaining_days) or the return value of I(cert_days) - the ECS API does not support the "renew" operation for certificates that are not
|
||||
at least 30 days old.
|
||||
type: bool
|
||||
default: false
|
||||
path:
|
||||
description:
|
||||
- The destination path for the generated certificate as a PEM encoded cert.
|
||||
- If the certificate at this location is not an Entrust issued certificate, a new certificate will always be requested even if the current
|
||||
certificate is technically valid.
|
||||
- If there is already an Entrust certificate at this location, whether it is replaced is depends on the I(remaining_days) calculation.
|
||||
- If an existing certificate is being replaced (see I(remaining_days), I(force), and I(tracking_id)), whether a new certificate is requested
|
||||
or the existing certificate is renewed or reissued is based on I(request_type).
|
||||
type: path
|
||||
required: true
|
||||
full_chain_path:
|
||||
description:
|
||||
- The destination path for the full certificate chain of the certificate, intermediates, and roots.
|
||||
type: path
|
||||
csr:
|
||||
description:
|
||||
- Base-64 encoded Certificate Signing Request (CSR). I(csr) is accepted with or without PEM formatting around the Base-64 string.
|
||||
- If no I(csr) is provided when C(request_type=reissue) or C(request_type=renew), the certificate will be generated with the same public key as
|
||||
the certificate being renewed or reissued.
|
||||
- If I(subject_alt_name) is specified, it will override the subject alternate names in the CSR.
|
||||
- If I(eku) is specified, it will override the extended key usage in the CSR.
|
||||
- If I(ou) is specified, it will override the organizational units "ou=" present in the subject distinguished name of the CSR, if any.
|
||||
- The organization "O=" field from the CSR will not be used. It will be replaced in the issued certificate by I(org) if present, and if not present,
|
||||
the organization tied to I(client_id).
|
||||
type: str
|
||||
tracking_id:
|
||||
description:
|
||||
- The tracking ID of the certificate to reissue or renew.
|
||||
- I(tracking_id) is invalid if C(request_type=new) or C(request_type=validate_only).
|
||||
- If there is a certificate present in I(path) and it is an ECS certificate, I(tracking_id) will be ignored.
|
||||
- If there is no certificate present in I(path) or there is but it is from another provider, the certificate represented by I(tracking_id) will
|
||||
be renewed or reissued and saved to I(path).
|
||||
- If there is no certificate present in I(path) and the I(force) and I(remaining_days) parameters do not indicate a new certificate is needed,
|
||||
the certificate referenced by I(tracking_id) certificate will be saved to I(path).
|
||||
- This can be used when a known certificate is not currently present on a server, but you want to renew or reissue it to be managed by an ansible
|
||||
playbook. For example, if you specify C(request_type=renew), I(tracking_id) of an issued certificate, and I(path) to a file that does not exist,
|
||||
the first run of a task will download the certificate specified by I(tracking_id) (assuming it is still valid). Future runs of the task will
|
||||
(if applicable - see I(force) and I(remaining_days)) renew the certificate now present in I(path).
|
||||
type: int
|
||||
remaining_days:
|
||||
description:
|
||||
- The number of days the certificate must have left being valid. If C(cert_days < remaining_days) then a new certificate will be
|
||||
obtained using I(request_type).
|
||||
- If C(request_type=renew), a renewal will fail if the certificate being renewed has been issued within the past 30 days, so do not set a
|
||||
I(remaining_days) value that is within 30 days of the full lifetime of the certificate being acted upon. (e.g. if you are requesting Certificates
|
||||
with a 90 day lifetime, do not set remaining_days to a value C(60) or higher).
|
||||
- The I(force) option may be used to ensure that a new certificate is always obtained.
|
||||
type: int
|
||||
default: 30
|
||||
request_type:
|
||||
description:
|
||||
- The operation performed if I(tracking_id) references a valid certificate to reissue, or there is already a certificate present in I(path) but
|
||||
either I(force) is specified or C(cert_days < remaining_days).
|
||||
- Specifying C(request_type=validate_only) means the request will be validated against the ECS API, but no certificate will be issued.
|
||||
- Specifying C(request_type=new) means a certificate request will always be submitted and a new certificate issued.
|
||||
- Specifying C(request_type=renew) means that an existing certificate (specified by I(tracking_id) if present, otherwise I(path)) will be renewed.
|
||||
If there is no certificate to renew, a new certificate is requested.
|
||||
- Specifying C(request_type=reissue) means that an existing certificate (specified by I(tracking_id) if present, otherwise I(path)) will be
|
||||
reissued.
|
||||
If there is no certificate to reissue, a new certificate is requested.
|
||||
- If a certificate was issued within the past 30 days, the 'renew' operation is not a valid operation and will fail.
|
||||
- Note that C(reissue) is an operation that will result in the revocation of the certificate that is reissued, be cautious with it's use.
|
||||
- I(check_mode) is only supported if C(request_type=new)
|
||||
- For example, setting C(request_type=renew) and C(remaining_days=30) and pointing to the same certificate on multiple playbook runs means that on
|
||||
the first run new certificate will be requested. It will then be left along on future runs until it is within 30 days of expiry, then the
|
||||
ECS "renew" operation will be performed.
|
||||
type: str
|
||||
choices: [ 'new', 'renew', 'reissue', 'validate_only']
|
||||
default: new
|
||||
cert_type:
|
||||
description:
|
||||
- Specify the type of certificate requested.
|
||||
- If a certificate is being reissued or renewed, this parameter is ignored, and the C(cert_type) of the initial certificate is used.
|
||||
type: str
|
||||
choices: [ 'STANDARD_SSL', 'ADVANTAGE_SSL', 'UC_SSL', 'EV_SSL', 'WILDCARD_SSL', 'PRIVATE_SSL', 'PD_SSL', 'CODE_SIGNING', 'EV_CODE_SIGNING',
|
||||
'CDS_INDIVIDUAL', 'CDS_GROUP', 'CDS_ENT_LITE', 'CDS_ENT_PRO', 'SMIME_ENT' ]
|
||||
subject_alt_name:
|
||||
description:
|
||||
- The subject alternative name identifiers, as an array of values (applies to I(cert_type) with a value of C(STANDARD_SSL), C(ADVANTAGE_SSL),
|
||||
C(UC_SSL), C(EV_SSL), C(WILDCARD_SSL), C(PRIVATE_SSL), and C(PD_SSL)).
|
||||
- If you are requesting a new SSL certificate, and you pass a I(subject_alt_name) parameter, any SAN names in the CSR are ignored.
|
||||
If no subjectAltName parameter is passed, the SAN names in the CSR are used.
|
||||
- See I(request_type) to understand more about SANs during reissues and renewals.
|
||||
- In the case of certificates of type C(STANDARD_SSL) certificates, if the CN of the certificate is <domain>.<tld> only the www.<domain>.<tld> value
|
||||
is accepted. If the CN of the certificate is www.<domain>.<tld> only the <domain>.<tld> value is accepted.
|
||||
type: list
|
||||
elements: str
|
||||
eku:
|
||||
description:
|
||||
- If specified, overrides the key usage in the I(csr).
|
||||
type: str
|
||||
choices: [ SERVER_AUTH, CLIENT_AUTH, SERVER_AND_CLIENT_AUTH ]
|
||||
ct_log:
|
||||
description:
|
||||
- In compliance with browser requirements, this certificate may be posted to the Certificate Transparency (CT) logs. This is a best practice
|
||||
technique that helps domain owners monitor certificates issued to their domains. Note that not all certificates are eligible for CT logging.
|
||||
- If I(ct_log) is not specified, the certificate uses the account default.
|
||||
- If I(ct_log) is specified and the account settings allow it, I(ct_log) overrides the account default.
|
||||
- If I(ct_log) is set to C(false), but the account settings are set to "always log", the certificate generation will fail.
|
||||
type: bool
|
||||
client_id:
|
||||
description:
|
||||
- The client ID to submit the Certificate Signing Request under.
|
||||
- If no client ID is specified, the certificate will be submitted under the primary client with ID of 1.
|
||||
- When using a client other than the primary client, the I(org) parameter cannot be specified.
|
||||
- The issued certificate will have an organization value in the subject distinguished name represented by the client.
|
||||
type: int
|
||||
default: 1
|
||||
org:
|
||||
description:
|
||||
- Organization "O=" to include in the certificate.
|
||||
- If I(org) is not specified, the organization from the client represented by I(client_id) is used.
|
||||
- Unless the I(cert_type) is C(PD_SSL), this field may not be specified if the value of I(client_id) is not "1" (the primary client).
|
||||
non-primary clients, certificates may only be issued with the organization of that client.
|
||||
type: str
|
||||
ou:
|
||||
description:
|
||||
- Organizational unit "OU=" to include in the certificate.
|
||||
- I(ou) behavior is dependent on whether organizational units are enabled for your account. If organizational unit support is disabled for your
|
||||
account, organizational units from the I(csr) and the I(ou) parameter are ignored.
|
||||
- If both I(csr) and I(ou) are specified, the value in I(ou) will override the OU fields present in the subject distinguished name in the I(csr)
|
||||
- If neither I(csr) nor I(ou) are specified for a renew or reissue operation, the OU fields in the initial certificate are reused.
|
||||
- An invalid OU from I(csr) is ignored, but any invalid organizational units in I(ou) will result in an error indicating "Unapproved OU". The I(ou)
|
||||
parameter can be used to force failure if an unapproved organizational unit is provided.
|
||||
- A maximum of one OU may be specified for current products. Multiple OUs are reserved for future products.
|
||||
type: list
|
||||
elements: str
|
||||
end_user_key_storage_agreement:
|
||||
description:
|
||||
- The end user of the Code Signing certificate must generate and store the private key for this request on cryptographically secure
|
||||
hardware to be compliant with the Entrust CSP and Subscription agreement. If requesting a certificate of type C(CODE_SIGNING) or
|
||||
C(EV_CODE_SIGNING), you must set I(end_user_key_storage_agreement) to true if and only if you acknowledge that you will inform the user of this
|
||||
requirement.
|
||||
- Applicable only to I(cert_type) of values C(CODE_SIGNING) and C(EV_CODE_SIGNING).
|
||||
type: bool
|
||||
tracking_info:
|
||||
description: Free form tracking information to attach to the record for the certificate.
|
||||
type: str
|
||||
requester_name:
|
||||
description: The requester name to associate with certificate tracking information.
|
||||
type: str
|
||||
required: true
|
||||
requester_email:
|
||||
description: The requester email to associate with certificate tracking information and receive delivery and expiry notices for the certificate.
|
||||
type: str
|
||||
required: true
|
||||
requester_phone:
|
||||
description: The requester phone number to associate with certificate tracking information.
|
||||
type: str
|
||||
required: true
|
||||
additional_emails:
|
||||
description: A list of additional email addresses to receive the delivery notice and expiry notification for the certificate.
|
||||
type: list
|
||||
elements: str
|
||||
custom_fields:
|
||||
description:
|
||||
- Mapping of custom fields to associate with the certificate request and certificate.
|
||||
- Only supported if custom fields are enabled for your account.
|
||||
- Each custom field specified must be a custom field you have defined for your account.
|
||||
type: dict
|
||||
suboptions:
|
||||
text1:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text2:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text3:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text4:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text5:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text6:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text7:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text8:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text9:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text10:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text11:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text12:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text13:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text14:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
text15:
|
||||
description: Custom text field (maximum 500 characters)
|
||||
type: str
|
||||
number1:
|
||||
description: Custom number field.
|
||||
type: float
|
||||
number2:
|
||||
description: Custom number field.
|
||||
type: float
|
||||
number3:
|
||||
description: Custom number field.
|
||||
type: float
|
||||
number4:
|
||||
description: Custom number field.
|
||||
type: float
|
||||
number5:
|
||||
description: Custom number field.
|
||||
type: float
|
||||
date1:
|
||||
description: Custom date field.
|
||||
type: str
|
||||
date2:
|
||||
description: Custom date field.
|
||||
type: str
|
||||
date3:
|
||||
description: Custom date field.
|
||||
type: str
|
||||
date4:
|
||||
description: Custom date field.
|
||||
type: str
|
||||
date5:
|
||||
description: Custom date field.
|
||||
type: str
|
||||
email1:
|
||||
description: Custom email field.
|
||||
type: str
|
||||
email2:
|
||||
description: Custom email field.
|
||||
type: str
|
||||
email3:
|
||||
description: Custom email field.
|
||||
type: str
|
||||
email4:
|
||||
description: Custom email field.
|
||||
type: str
|
||||
email5:
|
||||
description: Custom email field.
|
||||
type: str
|
||||
dropdown1:
|
||||
description: Custom dropdown field.
|
||||
type: str
|
||||
dropdown2:
|
||||
description: Custom dropdown field.
|
||||
type: str
|
||||
dropdown3:
|
||||
description: Custom dropdown field.
|
||||
type: str
|
||||
dropdown4:
|
||||
description: Custom dropdown field.
|
||||
type: str
|
||||
dropdown5:
|
||||
description: Custom dropdown field.
|
||||
type: str
|
||||
cert_expiry:
|
||||
description:
|
||||
- The date the certificate should be set to expire, in RFC3339 compliant date or date-time format. For example,
|
||||
C(2020-02-23), C(2020-02-23T15:00:00.05Z).
|
||||
- I(cert_expiry) is only supported for requests of C(request_type=new) or C(request_type=renew). If C(request_type=reissue),
|
||||
I(cert_expiry) will be used for the first certificate issuance, but subsequent issuances will have the same expiry as the initial
|
||||
certificate.
|
||||
- A reissued certificate will always have the same expiry as the original certificate.
|
||||
- Note that only the date (day, month, year) is supported for specifying the expiry date. If you choose to specify an expiry time with the expiry
|
||||
date, the time will be adjusted to Eastern Standard Time (EST). This could have the unintended effect of moving your expiry date to the previous
|
||||
day.
|
||||
- Applies only to accounts with a pooling inventory model.
|
||||
- Only one of I(cert_expiry) or I(cert_lifetime) may be specified.
|
||||
type: str
|
||||
cert_lifetime:
|
||||
description:
|
||||
- The lifetime of the certificate.
|
||||
- Applies to all certificates for accounts with a non-pooling inventory model.
|
||||
- I(cert_lifetime) is only supported for requests of C(request_type=new) or C(request_type=renew). If C(request_type=reissue), I(cert_lifetime) will
|
||||
be used for the first certificate issuance, but subsequent issuances will have the same expiry as the initial certificate.
|
||||
- Applies to certificates of I(cert_type)=C(CDS_INDIVIDUAL, CDS_GROUP, CDS_ENT_LITE, CDS_ENT_PRO, SMIME_ENT) for accounts with a pooling inventory
|
||||
model.
|
||||
- C(P1Y) is a certificate with a 1 year lifetime.
|
||||
- C(P2Y) is a certificate with a 2 year lifetime.
|
||||
- C(P3Y) is a certificate with a 3 year lifetime.
|
||||
- Only one of I(cert_expiry) or I(cert_lifetime) may be specified.
|
||||
type: str
|
||||
choices: [ P1Y, P2Y, P3Y ]
|
||||
seealso:
|
||||
- module: openssl_privatekey
|
||||
description: Can be used to create private keys (both for certificates and accounts).
|
||||
- module: openssl_csr
|
||||
description: Can be used to create a Certificate Signing Request (CSR).
|
||||
extends_documentation_fragment:
|
||||
- community.crypto.ecs_credential
|
||||
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Request a new certificate from Entrust with bare minimum parameters.
|
||||
Will request a new certificate if current one is valid but within 30
|
||||
days of expiry. If replacing an existing file in path, will back it up.
|
||||
ecs_certificate:
|
||||
backup: true
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
full_chain_path: /etc/ssl/crt/ansible.com.chain.crt
|
||||
csr: /etc/ssl/csr/ansible.com.csr
|
||||
cert_type: EV_SSL
|
||||
requester_name: Jo Doe
|
||||
requester_email: jdoe@ansible.com
|
||||
requester_phone: 555-555-5555
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: If there is no certificate present in path, request a new certificate
|
||||
of type EV_SSL. Otherwise, if there is an Entrust managed certificate
|
||||
in path and it is within 63 days of expiration, request a renew of that
|
||||
certificate.
|
||||
ecs_certificate:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
csr: /etc/ssl/csr/ansible.com.csr
|
||||
cert_type: EV_SSL
|
||||
cert_expiry: '2020-08-20'
|
||||
request_type: renew
|
||||
remaining_days: 63
|
||||
requester_name: Jo Doe
|
||||
requester_email: jdoe@ansible.com
|
||||
requester_phone: 555-555-5555
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: If there is no certificate present in path, download certificate
|
||||
specified by tracking_id if it is still valid. Otherwise, if the
|
||||
certificate is within 79 days of expiration, request a renew of that
|
||||
certificate and save it in path. This can be used to "migrate" a
|
||||
certificate to be Ansible managed.
|
||||
ecs_certificate:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
csr: /etc/ssl/csr/ansible.com.csr
|
||||
tracking_id: 2378915
|
||||
request_type: renew
|
||||
remaining_days: 79
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: Force a reissue of the certificate specified by tracking_id.
|
||||
ecs_certificate:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
force: true
|
||||
tracking_id: 2378915
|
||||
request_type: reissue
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: Request a new certificate with an alternative client. Note that the
|
||||
issued certificate will have it's Subject Distinguished Name use the
|
||||
organization details associated with that client, rather than what is
|
||||
in the CSR.
|
||||
ecs_certificate:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
csr: /etc/ssl/csr/ansible.com.csr
|
||||
client_id: 2
|
||||
requester_name: Jo Doe
|
||||
requester_email: jdoe@ansible.com
|
||||
requester_phone: 555-555-5555
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: Request a new certificate with a number of CSR parameters overridden
|
||||
and tracking information
|
||||
ecs_certificate:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
full_chain_path: /etc/ssl/crt/ansible.com.chain.crt
|
||||
csr: /etc/ssl/csr/ansible.com.csr
|
||||
subject_alt_name:
|
||||
- ansible.testcertificates.com
|
||||
- www.testcertificates.com
|
||||
eku: SERVER_AND_CLIENT_AUTH
|
||||
ct_log: true
|
||||
org: Test Organization Inc.
|
||||
ou:
|
||||
- Administration
|
||||
tracking_info: "Submitted via Ansible"
|
||||
additional_emails:
|
||||
- itsupport@testcertificates.com
|
||||
- jsmith@ansible.com
|
||||
custom_fields:
|
||||
text1: Admin
|
||||
text2: Invoice 25
|
||||
number1: 342
|
||||
date1: '2018-01-01'
|
||||
email1: sales@ansible.testcertificates.com
|
||||
dropdown1: red
|
||||
cert_expiry: '2020-08-15'
|
||||
requester_name: Jo Doe
|
||||
requester_email: jdoe@ansible.com
|
||||
requester_phone: 555-555-5555
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
filename:
|
||||
description: The destination path for the generated certificate.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /etc/ssl/crt/www.ansible.com.crt
|
||||
backup_file:
|
||||
description: Name of backup file created for the certificate.
|
||||
returned: changed and if I(backup) is C(true)
|
||||
type: str
|
||||
sample: /path/to/www.ansible.com.crt.2019-03-09@11:22~
|
||||
backup_full_chain_file:
|
||||
description: Name of the backup file created for the certificate chain.
|
||||
returned: changed and if I(backup) is C(true) and I(full_chain_path) is set.
|
||||
type: str
|
||||
sample: /path/to/ca.chain.crt.2019-03-09@11:22~
|
||||
tracking_id:
|
||||
description: The tracking ID to reference and track the certificate in ECS.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 380079
|
||||
serial_number:
|
||||
description: The serial number of the issued certificate.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 1235262234164342
|
||||
cert_days:
|
||||
description: The number of days the certificate remains valid.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 253
|
||||
cert_status:
|
||||
description:
|
||||
- The certificate status in ECS.
|
||||
- 'Current possible values (which may be expanded in the future) are: C(ACTIVE), C(APPROVED), C(DEACTIVATED), C(DECLINED), C(EXPIRED), C(NA),
|
||||
C(PENDING), C(PENDING_QUORUM), C(READY), C(REISSUED), C(REISSUING), C(RENEWED), C(RENEWING), C(REVOKED), C(SUSPENDED)'
|
||||
returned: success
|
||||
type: str
|
||||
sample: ACTIVE
|
||||
cert_details:
|
||||
description:
|
||||
- The full response JSON from the Get Certificate call of the ECS API.
|
||||
- 'While the response contents are guaranteed to be forwards compatible with new ECS API releases, Entrust recommends that you do not make any
|
||||
playbooks take actions based on the content of this field. However it may be useful for debugging, logging, or auditing purposes.'
|
||||
returned: success
|
||||
type: dict
|
||||
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.ecs.api import (
|
||||
ecs_client_argument_spec,
|
||||
ECSClient,
|
||||
RestOperationException,
|
||||
SessionConfigurationException,
|
||||
)
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_native, to_bytes
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
|
||||
|
||||
|
||||
def validate_cert_expiry(cert_expiry):
|
||||
search_string_partial = re.compile(r'^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])\Z')
|
||||
search_string_full = re.compile(r'^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):'
|
||||
r'([0-5][0-9]|60)(.[0-9]+)?(([Zz])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))\Z')
|
||||
if search_string_partial.match(cert_expiry) or search_string_full.match(cert_expiry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def calculate_cert_days(expires_after):
|
||||
cert_days = 0
|
||||
if expires_after:
|
||||
expires_after_datetime = datetime.datetime.strptime(expires_after, '%Y-%m-%dT%H:%M:%SZ')
|
||||
cert_days = (expires_after_datetime - datetime.datetime.now()).days
|
||||
return cert_days
|
||||
|
||||
|
||||
# Populate the value of body[dict_param_name] with the JSON equivalent of
|
||||
# module parameter of param_name if that parameter is present, otherwise leave field
|
||||
# out of resulting dict
|
||||
def convert_module_param_to_json_bool(module, dict_param_name, param_name):
|
||||
body = {}
|
||||
if module.params[param_name] is not None:
|
||||
if module.params[param_name]:
|
||||
body[dict_param_name] = 'true'
|
||||
else:
|
||||
body[dict_param_name] = 'false'
|
||||
return body
|
||||
|
||||
|
||||
class EcsCertificate(object):
|
||||
'''
|
||||
Entrust Certificate Services certificate class.
|
||||
'''
|
||||
|
||||
def __init__(self, module):
|
||||
self.path = module.params['path']
|
||||
self.full_chain_path = module.params['full_chain_path']
|
||||
self.force = module.params['force']
|
||||
self.backup = module.params['backup']
|
||||
self.request_type = module.params['request_type']
|
||||
self.csr = module.params['csr']
|
||||
|
||||
# All return values
|
||||
self.changed = False
|
||||
self.filename = None
|
||||
self.tracking_id = None
|
||||
self.cert_status = None
|
||||
self.serial_number = None
|
||||
self.cert_days = None
|
||||
self.cert_details = None
|
||||
self.backup_file = None
|
||||
self.backup_full_chain_file = None
|
||||
|
||||
self.cert = None
|
||||
self.ecs_client = None
|
||||
if self.path and os.path.exists(self.path):
|
||||
try:
|
||||
self.cert = crypto_utils.load_certificate(self.path, backend='cryptography')
|
||||
except Exception as dummy:
|
||||
self.cert = None
|
||||
# Instantiate the ECS client and then try a no-op connection to verify credentials are valid
|
||||
try:
|
||||
self.ecs_client = ECSClient(
|
||||
entrust_api_user=module.params['entrust_api_user'],
|
||||
entrust_api_key=module.params['entrust_api_key'],
|
||||
entrust_api_cert=module.params['entrust_api_client_cert_path'],
|
||||
entrust_api_cert_key=module.params['entrust_api_client_cert_key_path'],
|
||||
entrust_api_specification_path=module.params['entrust_api_specification_path']
|
||||
)
|
||||
except SessionConfigurationException as e:
|
||||
module.fail_json(msg='Failed to initialize Entrust Provider: {0}'.format(to_native(e)))
|
||||
try:
|
||||
self.ecs_client.GetAppVersion()
|
||||
except RestOperationException as e:
|
||||
module.fail_json(msg='Please verify credential information. Received exception when testing ECS connection: {0}'.format(to_native(e.message)))
|
||||
|
||||
# Conversion of the fields that go into the 'tracking' parameter of the request object
|
||||
def convert_tracking_params(self, module):
|
||||
body = {}
|
||||
tracking = {}
|
||||
if module.params['requester_name']:
|
||||
tracking['requesterName'] = module.params['requester_name']
|
||||
if module.params['requester_email']:
|
||||
tracking['requesterEmail'] = module.params['requester_email']
|
||||
if module.params['requester_phone']:
|
||||
tracking['requesterPhone'] = module.params['requester_phone']
|
||||
if module.params['tracking_info']:
|
||||
tracking['trackingInfo'] = module.params['tracking_info']
|
||||
if module.params['custom_fields']:
|
||||
# Omit custom fields from submitted dict if not present, instead of submitting them with value of 'null'
|
||||
# The ECS API does technically accept null without error, but it complicates debugging user escalations and is unnecessary bandwidth.
|
||||
custom_fields = {}
|
||||
for k, v in module.params['custom_fields'].items():
|
||||
if v is not None:
|
||||
custom_fields[k] = v
|
||||
tracking['customFields'] = custom_fields
|
||||
if module.params['additional_emails']:
|
||||
tracking['additionalEmails'] = module.params['additional_emails']
|
||||
body['tracking'] = tracking
|
||||
return body
|
||||
|
||||
def convert_cert_subject_params(self, module):
|
||||
body = {}
|
||||
if module.params['subject_alt_name']:
|
||||
body['subjectAltName'] = module.params['subject_alt_name']
|
||||
if module.params['org']:
|
||||
body['org'] = module.params['org']
|
||||
if module.params['ou']:
|
||||
body['ou'] = module.params['ou']
|
||||
return body
|
||||
|
||||
def convert_general_params(self, module):
|
||||
body = {}
|
||||
if module.params['eku']:
|
||||
body['eku'] = module.params['eku']
|
||||
if self.request_type == 'new':
|
||||
body['certType'] = module.params['cert_type']
|
||||
body['clientId'] = module.params['client_id']
|
||||
body.update(convert_module_param_to_json_bool(module, 'ctLog', 'ct_log'))
|
||||
body.update(convert_module_param_to_json_bool(module, 'endUserKeyStorageAgreement', 'end_user_key_storage_agreement'))
|
||||
return body
|
||||
|
||||
def convert_expiry_params(self, module):
|
||||
body = {}
|
||||
if module.params['cert_lifetime']:
|
||||
body['certLifetime'] = module.params['cert_lifetime']
|
||||
elif module.params['cert_expiry']:
|
||||
body['certExpiryDate'] = module.params['cert_expiry']
|
||||
# If neither cerTLifetime or certExpiryDate was specified and the request type is new, default to 365 days
|
||||
elif self.request_type != 'reissue':
|
||||
gmt_now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime()))
|
||||
expiry = gmt_now + datetime.timedelta(days=365)
|
||||
body['certExpiryDate'] = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z")
|
||||
return body
|
||||
|
||||
def set_tracking_id_by_serial_number(self, module):
|
||||
try:
|
||||
# Use serial_number to identify if certificate is an Entrust Certificate
|
||||
# with an associated tracking ID
|
||||
serial_number = "{0:X}".format(self.cert.serial_number)
|
||||
cert_results = self.ecs_client.GetCertificates(serialNumber=serial_number).get('certificates', {})
|
||||
if len(cert_results) == 1:
|
||||
self.tracking_id = cert_results[0].get('trackingId')
|
||||
except RestOperationException as dummy:
|
||||
# If we fail to find a cert by serial number, that's fine, we just don't set self.tracking_id
|
||||
return
|
||||
|
||||
def set_cert_details(self, module):
|
||||
try:
|
||||
self.cert_details = self.ecs_client.GetCertificate(trackingId=self.tracking_id)
|
||||
self.cert_status = self.cert_details.get('status')
|
||||
self.serial_number = self.cert_details.get('serialNumber')
|
||||
self.cert_days = calculate_cert_days(self.cert_details.get('expiresAfter'))
|
||||
except RestOperationException as e:
|
||||
module.fail_json('Failed to get details of certificate with tracking_id="{0}", Error: '.format(self.tracking_id), to_native(e.message))
|
||||
|
||||
def check(self, module):
|
||||
if self.cert:
|
||||
# We will only treat a certificate as valid if it is found as a managed entrust cert.
|
||||
# We will only set updated tracking ID based on certificate in "path" if it is managed by entrust.
|
||||
self.set_tracking_id_by_serial_number(module)
|
||||
|
||||
if module.params['tracking_id'] and self.tracking_id and module.params['tracking_id'] != self.tracking_id:
|
||||
module.warn('tracking_id parameter of "{0}" provided, but will be ignored. Valid certificate was present in path "{1}" with '
|
||||
'tracking_id of "{2}".'.format(module.params['tracking_id'], self.path, self.tracking_id))
|
||||
|
||||
# If we did not end up setting tracking_id based on existing cert, get from module params
|
||||
if not self.tracking_id:
|
||||
self.tracking_id = module.params['tracking_id']
|
||||
|
||||
if not self.tracking_id:
|
||||
return False
|
||||
|
||||
self.set_cert_details(module)
|
||||
|
||||
if self.cert_status == 'EXPIRED' or self.cert_status == 'SUSPENDED' or self.cert_status == 'REVOKED':
|
||||
return False
|
||||
if self.cert_days < module.params['remaining_days']:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def request_cert(self, module):
|
||||
if not self.check(module) or self.force:
|
||||
body = {}
|
||||
|
||||
# Read the CSR contents
|
||||
if self.csr and os.path.exists(self.csr):
|
||||
with open(self.csr, 'r') as csr_file:
|
||||
body['csr'] = csr_file.read()
|
||||
|
||||
# Check if the path is already a cert
|
||||
# tracking_id may be set as a parameter or by get_cert_details if an entrust cert is in 'path'. If tracking ID is null
|
||||
# We will be performing a reissue operation.
|
||||
if self.request_type != 'new' and not self.tracking_id:
|
||||
module.warn('No existing Entrust certificate found in path={0} and no tracking_id was provided, setting request_type to "new" for this task'
|
||||
'run. Future playbook runs that point to the pathination file in {1} will use request_type={2}'
|
||||
.format(self.path, self.path, self.request_type))
|
||||
self.request_type = 'new'
|
||||
elif self.request_type == 'new' and self.tracking_id:
|
||||
module.warn('Existing certificate being acted upon, but request_type is "new", so will be a new certificate issuance rather than a'
|
||||
'reissue or renew')
|
||||
# Use cases where request type is new and no existing certificate, or where request type is reissue/renew and a valid
|
||||
# existing certificate is found, do not need warnings.
|
||||
|
||||
body.update(self.convert_tracking_params(module))
|
||||
body.update(self.convert_cert_subject_params(module))
|
||||
body.update(self.convert_general_params(module))
|
||||
body.update(self.convert_expiry_params(module))
|
||||
|
||||
if not module.check_mode:
|
||||
try:
|
||||
if self.request_type == 'validate_only':
|
||||
body['validateOnly'] = 'true'
|
||||
result = self.ecs_client.NewCertRequest(Body=body)
|
||||
if self.request_type == 'new':
|
||||
result = self.ecs_client.NewCertRequest(Body=body)
|
||||
elif self.request_type == 'renew':
|
||||
result = self.ecs_client.RenewCertRequest(trackingId=self.tracking_id, Body=body)
|
||||
elif self.request_type == 'reissue':
|
||||
result = self.ecs_client.ReissueCertRequest(trackingId=self.tracking_id, Body=body)
|
||||
self.tracking_id = result.get('trackingId')
|
||||
self.set_cert_details(module)
|
||||
except RestOperationException as e:
|
||||
module.fail_json(msg='Failed to request new certificate from Entrust (ECS) {0}'.format(e.message))
|
||||
|
||||
if self.request_type != 'validate_only':
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
crypto_utils.write_file(module, to_bytes(self.cert_details.get('endEntityCert')))
|
||||
if self.full_chain_path and self.cert_details.get('chainCerts'):
|
||||
if self.backup:
|
||||
self.backup_full_chain_file = module.backup_local(self.full_chain_path)
|
||||
chain_string = '\n'.join(self.cert_details.get('chainCerts')) + '\n'
|
||||
crypto_utils.write_file(module, to_bytes(chain_string), path=self.full_chain_path)
|
||||
self.changed = True
|
||||
# If there is no certificate present in path but a tracking ID was specified, save it to disk
|
||||
elif not os.path.exists(self.path) and self.tracking_id:
|
||||
if not module.check_mode:
|
||||
crypto_utils.write_file(module, to_bytes(self.cert_details.get('endEntityCert')))
|
||||
if self.full_chain_path and self.cert_details.get('chainCerts'):
|
||||
chain_string = '\n'.join(self.cert_details.get('chainCerts')) + '\n'
|
||||
crypto_utils.write_file(module, to_bytes(chain_string), path=self.full_chain_path)
|
||||
self.changed = True
|
||||
|
||||
def dump(self):
|
||||
result = {
|
||||
'changed': self.changed,
|
||||
'filename': self.path,
|
||||
'tracking_id': self.tracking_id,
|
||||
'cert_status': self.cert_status,
|
||||
'serial_number': self.serial_number,
|
||||
'cert_days': self.cert_days,
|
||||
'cert_details': self.cert_details,
|
||||
}
|
||||
if self.backup_file:
|
||||
result['backup_file'] = self.backup_file
|
||||
result['backup_full_chain_file'] = self.backup_full_chain_file
|
||||
return result
|
||||
|
||||
|
||||
def custom_fields_spec():
|
||||
return dict(
|
||||
text1=dict(type='str'),
|
||||
text2=dict(type='str'),
|
||||
text3=dict(type='str'),
|
||||
text4=dict(type='str'),
|
||||
text5=dict(type='str'),
|
||||
text6=dict(type='str'),
|
||||
text7=dict(type='str'),
|
||||
text8=dict(type='str'),
|
||||
text9=dict(type='str'),
|
||||
text10=dict(type='str'),
|
||||
text11=dict(type='str'),
|
||||
text12=dict(type='str'),
|
||||
text13=dict(type='str'),
|
||||
text14=dict(type='str'),
|
||||
text15=dict(type='str'),
|
||||
number1=dict(type='float'),
|
||||
number2=dict(type='float'),
|
||||
number3=dict(type='float'),
|
||||
number4=dict(type='float'),
|
||||
number5=dict(type='float'),
|
||||
date1=dict(type='str'),
|
||||
date2=dict(type='str'),
|
||||
date3=dict(type='str'),
|
||||
date4=dict(type='str'),
|
||||
date5=dict(type='str'),
|
||||
email1=dict(type='str'),
|
||||
email2=dict(type='str'),
|
||||
email3=dict(type='str'),
|
||||
email4=dict(type='str'),
|
||||
email5=dict(type='str'),
|
||||
dropdown1=dict(type='str'),
|
||||
dropdown2=dict(type='str'),
|
||||
dropdown3=dict(type='str'),
|
||||
dropdown4=dict(type='str'),
|
||||
dropdown5=dict(type='str'),
|
||||
)
|
||||
|
||||
|
||||
def ecs_certificate_argument_spec():
|
||||
return dict(
|
||||
backup=dict(type='bool', default=False),
|
||||
force=dict(type='bool', default=False),
|
||||
path=dict(type='path', required=True),
|
||||
full_chain_path=dict(type='path'),
|
||||
tracking_id=dict(type='int'),
|
||||
remaining_days=dict(type='int', default=30),
|
||||
request_type=dict(type='str', default='new', choices=['new', 'renew', 'reissue', 'validate_only']),
|
||||
cert_type=dict(type='str', choices=['STANDARD_SSL',
|
||||
'ADVANTAGE_SSL',
|
||||
'UC_SSL',
|
||||
'EV_SSL',
|
||||
'WILDCARD_SSL',
|
||||
'PRIVATE_SSL',
|
||||
'PD_SSL',
|
||||
'CODE_SIGNING',
|
||||
'EV_CODE_SIGNING',
|
||||
'CDS_INDIVIDUAL',
|
||||
'CDS_GROUP',
|
||||
'CDS_ENT_LITE',
|
||||
'CDS_ENT_PRO',
|
||||
'SMIME_ENT',
|
||||
]),
|
||||
csr=dict(type='str'),
|
||||
subject_alt_name=dict(type='list', elements='str'),
|
||||
eku=dict(type='str', choices=['SERVER_AUTH', 'CLIENT_AUTH', 'SERVER_AND_CLIENT_AUTH']),
|
||||
ct_log=dict(type='bool'),
|
||||
client_id=dict(type='int', default=1),
|
||||
org=dict(type='str'),
|
||||
ou=dict(type='list', elements='str'),
|
||||
end_user_key_storage_agreement=dict(type='bool'),
|
||||
tracking_info=dict(type='str'),
|
||||
requester_name=dict(type='str', required=True),
|
||||
requester_email=dict(type='str', required=True),
|
||||
requester_phone=dict(type='str', required=True),
|
||||
additional_emails=dict(type='list', elements='str'),
|
||||
custom_fields=dict(type='dict', default=None, options=custom_fields_spec()),
|
||||
cert_expiry=dict(type='str'),
|
||||
cert_lifetime=dict(type='str', choices=['P1Y', 'P2Y', 'P3Y']),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
ecs_argument_spec = ecs_client_argument_spec()
|
||||
ecs_argument_spec.update(ecs_certificate_argument_spec())
|
||||
module = AnsibleModule(
|
||||
argument_spec=ecs_argument_spec,
|
||||
required_if=(
|
||||
['request_type', 'new', ['cert_type']],
|
||||
['request_type', 'validate_only', ['cert_type']],
|
||||
['cert_type', 'CODE_SIGNING', ['end_user_key_storage_agreement']],
|
||||
['cert_type', 'EV_CODE_SIGNING', ['end_user_key_storage_agreement']],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['cert_expiry', 'cert_lifetime'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
if not CRYPTOGRAPHY_FOUND or CRYPTOGRAPHY_VERSION < LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION):
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
# If validate_only is used, pointing to an existing tracking_id is an invalid operation
|
||||
if module.params['tracking_id']:
|
||||
if module.params['request_type'] == 'new' or module.params['request_type'] == 'validate_only':
|
||||
module.fail_json(msg='The tracking_id field is invalid when request_type="{0}".'.format(module.params['request_type']))
|
||||
|
||||
# A reissued request can not specify an expiration date or lifetime
|
||||
if module.params['request_type'] == 'reissue':
|
||||
if module.params['cert_expiry']:
|
||||
module.fail_json(msg='The cert_expiry field is invalid when request_type="reissue".')
|
||||
elif module.params['cert_lifetime']:
|
||||
module.fail_json(msg='The cert_lifetime field is invalid when request_type="reissue".')
|
||||
# Only a reissued request can omit the CSR
|
||||
else:
|
||||
module_params_csr = module.params['csr']
|
||||
if module_params_csr is None:
|
||||
module.fail_json(msg='The csr field is required when request_type={0}'.format(module.params['request_type']))
|
||||
elif not os.path.exists(module_params_csr):
|
||||
module.fail_json(msg='The csr field of {0} was not a valid path. csr is required when request_type={1}'.format(
|
||||
module_params_csr, module.params['request_type']))
|
||||
|
||||
if module.params['ou'] and len(module.params['ou']) > 1:
|
||||
module.fail_json(msg='Multiple "ou" values are not currently supported.')
|
||||
|
||||
if module.params['end_user_key_storage_agreement']:
|
||||
if module.params['cert_type'] != 'CODE_SIGNING' and module.params['cert_type'] != 'EV_CODE_SIGNING':
|
||||
module.fail_json(msg='Parameter "end_user_key_storage_agreement" is valid only for cert_types "CODE_SIGNING" and "EV_CODE_SIGNING"')
|
||||
|
||||
if module.params['org'] and module.params['client_id'] != 1 and module.params['cert_type'] != 'PD_SSL':
|
||||
module.fail_json(msg='The "org" parameter is not supported when client_id parameter is set to a value other than 1, unless cert_type is "PD_SSL".')
|
||||
|
||||
if module.params['cert_expiry']:
|
||||
if not validate_cert_expiry(module.params['cert_expiry']):
|
||||
module.fail_json(msg='The "cert_expiry" parameter of "{0}" is not a valid date or date-time'.format(module.params['cert_expiry']))
|
||||
|
||||
certificate = EcsCertificate(module)
|
||||
certificate.request_cert(module)
|
||||
result = certificate.dump()
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
409
plugins/modules/ecs_domain.py
Normal file
409
plugins/modules/ecs_domain.py
Normal file
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2019 Entrust Datacard Corporation.
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: ecs_domain
|
||||
author:
|
||||
- Chris Trufan (@ctrufan)
|
||||
short_description: Request validation of a domain with the Entrust Certificate Services (ECS) API
|
||||
description:
|
||||
- Request validation or re-validation of a domain with the Entrust Certificate Services (ECS) API.
|
||||
- Requires credentials for the L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API.
|
||||
- If the domain is already in the validation process, no new validation will be requested, but the validation data (if applicable) will be returned.
|
||||
- If the domain is already in the validation process but the I(verification_method) specified is different than the current I(verification_method),
|
||||
the I(verification_method) will be updated and validation data (if applicable) will be returned.
|
||||
- If the domain is an active, validated domain, the return value of I(changed) will be false, unless C(domain_status=EXPIRED), in which case a re-validation
|
||||
will be performed.
|
||||
- If C(verification_method=dns), details about the required DNS entry will be specified in the return parameters I(dns_contents), I(dns_location), and
|
||||
I(dns_resource_type).
|
||||
- If C(verification_method=web_server), details about the required file details will be specified in the return parameters I(file_contents) and
|
||||
I(file_location).
|
||||
- If C(verification_method=email), the email address(es) that the validation email(s) were sent to will be in the return parameter I(emails). This is
|
||||
purely informational. For domains requested using this module, this will always be a list of size 1.
|
||||
notes:
|
||||
- There is a small delay (typically about 5 seconds, but can be as long as 60 seconds) before obtaining the random values when requesting a validation
|
||||
while C(verification_method=dns) or C(verification_method=web_server). Be aware of that if doing many domain validation requests.
|
||||
options:
|
||||
client_id:
|
||||
description:
|
||||
- The client ID to request the domain be associated with.
|
||||
- If no client ID is specified, the domain will be added under the primary client with ID of 1.
|
||||
type: int
|
||||
default: 1
|
||||
domain_name:
|
||||
description:
|
||||
- The domain name to be verified or reverified.
|
||||
type: str
|
||||
required: true
|
||||
verification_method:
|
||||
description:
|
||||
- The verification method to be used to prove control of the domain.
|
||||
- If C(verification_method=email) and the value I(verification_email) is specified, that value is used for the email validation. If
|
||||
I(verification_email) is not provided, the first value present in WHOIS data will be used. An email will be sent to the address in
|
||||
I(verification_email) with instructions on how to verify control of the domain.
|
||||
- If C(verification_method=dns), the value I(dns_contents) must be stored in location I(dns_location), with a DNS record type of
|
||||
I(verification_dns_record_type). To prove domain ownership, update your DNS records so the text string returned by I(dns_contents) is available at
|
||||
I(dns_location).
|
||||
- If C(verification_method=web_server), the contents of return value I(file_contents) must be made available on a web server accessible at location
|
||||
I(file_location).
|
||||
- If C(verification_method=manual), the domain will be validated with a manual process. This is not recommended.
|
||||
type: str
|
||||
choices: [ 'dns', 'email', 'manual', 'web_server']
|
||||
required: true
|
||||
verification_email:
|
||||
description:
|
||||
- Email address to be used to verify domain ownership.
|
||||
- 'Email address must be either an email address present in the WHOIS data for I(domain_name), or one of the following constructed emails:
|
||||
admin@I(domain_name), administrator@I(domain_name), webmaster@I(domain_name), hostmaster@I(domain_name), postmaster@I(domain_name)'
|
||||
- 'Note that if I(domain_name) includes subdomains, the top level domain should be used. For example, if requesting validation of
|
||||
example1.ansible.com, or test.example2.ansible.com, and you want to use the "admin" preconstructed name, the email address should be
|
||||
admin@ansible.com.'
|
||||
- If using the email values from the WHOIS data for the domain or it's top level namespace, they must be exact matches.
|
||||
- If C(verification_method=email) but I(verification_email) is not provided, the first email address found in WHOIS data for the domain will be
|
||||
used.
|
||||
- To verify domain ownership, domain owner must follow the instructions in the email they receive.
|
||||
- Only allowed if C(verification_method=email)
|
||||
type: str
|
||||
seealso:
|
||||
- module: openssl_certificate
|
||||
description: Can be used to request certificates from ECS, with C(provider=entrust).
|
||||
- module: ecs_certificate
|
||||
description: Can be used to request a Certificate from ECS using a verified domain.
|
||||
extends_documentation_fragment:
|
||||
- community.crypto.ecs_credential
|
||||
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Request domain validation using email validation for client ID of 2.
|
||||
ecs_domain:
|
||||
domain_name: ansible.com
|
||||
client_id: 2
|
||||
verification_method: email
|
||||
verification_email: admin@ansible.com
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: Request domain validation using DNS. If domain is already valid,
|
||||
request revalidation if expires within 90 days
|
||||
ecs_domain:
|
||||
domain_name: ansible.com
|
||||
verification_method: dns
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: Request domain validation using web server validation, and revalidate
|
||||
if fewer than 60 days remaining of EV eligibility.
|
||||
ecs_domain:
|
||||
domain_name: ansible.com
|
||||
verification_method: web_server
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
|
||||
- name: Request domain validation using manual validation.
|
||||
ecs_domain:
|
||||
domain_name: ansible.com
|
||||
verification_method: manual
|
||||
entrust_api_user: apiusername
|
||||
entrust_api_key: a^lv*32!cd9LnT
|
||||
entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt
|
||||
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-client.key
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
domain_status:
|
||||
description: Status of the current domain. Will be one of C(APPROVED), C(DECLINED), C(CANCELLED), C(INITIAL_VERIFICATION), C(DECLINED), C(CANCELLED),
|
||||
C(RE_VERIFICATION), C(EXPIRED), C(EXPIRING)
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: APPROVED
|
||||
verification_method:
|
||||
description: Verification method used to request the domain validation. If C(changed) will be the same as I(verification_method) input parameter.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: dns
|
||||
file_location:
|
||||
description: The location that ECS will be expecting to be able to find the file for domain verification, containing the contents of I(file_contents).
|
||||
returned: I(verification_method) is C(web_server)
|
||||
type: str
|
||||
sample: http://ansible.com/.well-known/pki-validation/abcd.txt
|
||||
file_contents:
|
||||
description: The contents of the file that ECS will be expecting to find at C(file_location).
|
||||
returned: I(verification_method) is C(web_server)
|
||||
type: str
|
||||
sample: AB23CD41432522FF2526920393982FAB
|
||||
emails:
|
||||
description:
|
||||
- The list of emails used to request validation of this domain.
|
||||
- Domains requested using this module will only have a list of size 1.
|
||||
returned: I(verification_method) is C(email)
|
||||
type: list
|
||||
sample: [ admin@ansible.com, administrator@ansible.com ]
|
||||
dns_location:
|
||||
description: The location that ECS will be expecting to be able to find the DNS entry for domain verification, containing the contents of I(dns_contents).
|
||||
returned: changed and if I(verification_method) is C(dns)
|
||||
type: str
|
||||
sample: _pki-validation.ansible.com
|
||||
dns_contents:
|
||||
description: The value that ECS will be expecting to find in the DNS record located at I(dns_location).
|
||||
returned: changed and if I(verification_method) is C(dns)
|
||||
type: str
|
||||
sample: AB23CD41432522FF2526920393982FAB
|
||||
dns_resource_type:
|
||||
description: The type of resource record that ECS will be expecting for the DNS record located at I(dns_location).
|
||||
returned: changed and if I(verification_method) is C(dns)
|
||||
type: str
|
||||
sample: TXT
|
||||
client_id:
|
||||
description: Client ID that the domain belongs to. If the input value I(client_id) is specified, this will always be the same as I(client_id)
|
||||
returned: changed or success
|
||||
type: int
|
||||
sample: 1
|
||||
ov_eligible:
|
||||
description: Whether the domain is eligible for submission of "OV" certificates. Will never be C(false) if I(ov_eligible) is C(true)
|
||||
returned: success and I(domain_status) is C(APPROVED), C(RE_VERIFICATION), C(EXPIRING), or C(EXPIRED).
|
||||
type: bool
|
||||
sample: true
|
||||
ov_days_remaining:
|
||||
description: The number of days the domain remains eligible for submission of "OV" certificates. Will never be less than the value of I(ev_days_remaining)
|
||||
returned: success and I(ov_eligible) is C(true) and I(domain_status) is C(APPROVED), C(RE_VERIFICATION) or C(EXPIRING).
|
||||
type: int
|
||||
sample: 129
|
||||
ev_eligible:
|
||||
description: Whether the domain is eligible for submission of "EV" certificates. Will never be C(true) if I(ov_eligible) is C(false)
|
||||
returned: success and I(domain_status) is C(APPROVED), C(RE_VERIFICATION) or C(EXPIRING), or C(EXPIRED).
|
||||
type: bool
|
||||
sample: true
|
||||
ev_days_remaining:
|
||||
description: The number of days the domain remains eligible for submission of "EV" certificates. Will never be greater than the value of
|
||||
I(ov_days_remaining)
|
||||
returned: success and I(ev_eligible) is C(true) and I(domain_status) is C(APPROVED), C(RE_VERIFICATION) or C(EXPIRING).
|
||||
type: int
|
||||
sample: 94
|
||||
|
||||
'''
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.ecs.api import (
|
||||
ecs_client_argument_spec,
|
||||
ECSClient,
|
||||
RestOperationException,
|
||||
SessionConfigurationException,
|
||||
)
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils._text import to_native
|
||||
|
||||
|
||||
def calculate_days_remaining(expiry_date):
|
||||
days_remaining = None
|
||||
if expiry_date:
|
||||
expiry_datetime = datetime.datetime.strptime(expiry_date, '%Y-%m-%dT%H:%M:%SZ')
|
||||
days_remaining = (expiry_datetime - datetime.datetime.now()).days
|
||||
return days_remaining
|
||||
|
||||
|
||||
class EcsDomain(object):
|
||||
'''
|
||||
Entrust Certificate Services domain class.
|
||||
'''
|
||||
|
||||
def __init__(self, module):
|
||||
self.changed = False
|
||||
self.domain_status = None
|
||||
self.verification_method = None
|
||||
self.file_location = None
|
||||
self.file_contents = None
|
||||
self.dns_location = None
|
||||
self.dns_contents = None
|
||||
self.dns_resource_type = None
|
||||
self.emails = None
|
||||
self.ov_eligible = None
|
||||
self.ov_days_remaining = None
|
||||
self.ev_eligble = None
|
||||
self.ev_days_remaining = None
|
||||
# Note that verification_method is the 'current' verification
|
||||
# method of the domain, we'll use module.params when requesting a new
|
||||
# one, in case the verification method has changed.
|
||||
self.verification_method = None
|
||||
|
||||
self.ecs_client = None
|
||||
# Instantiate the ECS client and then try a no-op connection to verify credentials are valid
|
||||
try:
|
||||
self.ecs_client = ECSClient(
|
||||
entrust_api_user=module.params['entrust_api_user'],
|
||||
entrust_api_key=module.params['entrust_api_key'],
|
||||
entrust_api_cert=module.params['entrust_api_client_cert_path'],
|
||||
entrust_api_cert_key=module.params['entrust_api_client_cert_key_path'],
|
||||
entrust_api_specification_path=module.params['entrust_api_specification_path']
|
||||
)
|
||||
except SessionConfigurationException as e:
|
||||
module.fail_json(msg='Failed to initialize Entrust Provider: {0}'.format(to_native(e)))
|
||||
try:
|
||||
self.ecs_client.GetAppVersion()
|
||||
except RestOperationException as e:
|
||||
module.fail_json(msg='Please verify credential information. Received exception when testing ECS connection: {0}'.format(to_native(e.message)))
|
||||
|
||||
def set_domain_details(self, domain_details):
|
||||
if domain_details.get('verificationMethod'):
|
||||
self.verification_method = domain_details['verificationMethod'].lower()
|
||||
self.domain_status = domain_details['verificationStatus']
|
||||
self.ov_eligible = domain_details.get('ovEligible')
|
||||
self.ov_days_remaining = calculate_days_remaining(domain_details.get('ovExpiry'))
|
||||
self.ev_eligible = domain_details.get('evEligible')
|
||||
self.ev_days_remaining = calculate_days_remaining(domain_details.get('evExpiry'))
|
||||
self.client_id = domain_details['clientId']
|
||||
|
||||
if self.verification_method == 'dns' and domain_details.get('dnsMethod'):
|
||||
self.dns_location = domain_details['dnsMethod']['recordDomain']
|
||||
self.dns_resource_type = domain_details['dnsMethod']['recordType']
|
||||
self.dns_contents = domain_details['dnsMethod']['recordValue']
|
||||
elif self.verification_method == 'web_server' and domain_details.get('webServerMethod'):
|
||||
self.file_location = domain_details['webServerMethod']['fileLocation']
|
||||
self.file_contents = domain_details['webServerMethod']['fileContents']
|
||||
elif self.verification_method == 'email' and domain_details.get('emailMethod'):
|
||||
self.emails = domain_details['emailMethod']
|
||||
|
||||
def check(self, module):
|
||||
try:
|
||||
domain_details = self.ecs_client.GetDomain(clientId=module.params['client_id'], domain=module.params['domain_name'])
|
||||
self.set_domain_details(domain_details)
|
||||
if self.domain_status != 'APPROVED' and self.domain_status != 'INITIAL_VERIFICATION' and self.domain_status != 'RE_VERIFICATION':
|
||||
return False
|
||||
|
||||
# If domain verification is in process, we want to return the random values and treat it as a valid.
|
||||
if self.domain_status == 'INITIAL_VERIFICATION' or self.domain_status == 'RE_VERIFICATION':
|
||||
# Unless the verification method has changed, in which case we need to do a reverify request.
|
||||
if self.verification_method != module.params['verification_method']:
|
||||
return False
|
||||
|
||||
if self.domain_status == 'EXPIRING':
|
||||
return False
|
||||
|
||||
return True
|
||||
except RestOperationException as dummy:
|
||||
return False
|
||||
|
||||
def request_domain(self, module):
|
||||
if not self.check(module):
|
||||
body = {}
|
||||
|
||||
body['verificationMethod'] = module.params['verification_method'].upper()
|
||||
if module.params['verification_method'] == 'email':
|
||||
emailMethod = {}
|
||||
if module.params['verification_email']:
|
||||
emailMethod['emailSource'] = 'SPECIFIED'
|
||||
emailMethod['email'] = module.params['verification_email']
|
||||
else:
|
||||
emailMethod['emailSource'] = 'INCLUDE_WHOIS'
|
||||
body['emailMethod'] = emailMethod
|
||||
# Only populate domain name in body if it is not an existing domain
|
||||
if not self.domain_status:
|
||||
body['domainName'] = module.params['domain_name']
|
||||
try:
|
||||
if not self.domain_status:
|
||||
self.ecs_client.AddDomain(clientId=module.params['client_id'], Body=body)
|
||||
else:
|
||||
self.ecs_client.ReverifyDomain(clientId=module.params['client_id'], domain=module.params['domain_name'], Body=body)
|
||||
|
||||
time.sleep(5)
|
||||
result = self.ecs_client.GetDomain(clientId=module.params['client_id'], domain=module.params['domain_name'])
|
||||
|
||||
# It takes a bit of time before the random values are available
|
||||
if module.params['verification_method'] == 'dns' or module.params['verification_method'] == 'web_server':
|
||||
for i in range(4):
|
||||
# Check both that random values are now available, and that they're different than were populated by previous 'check'
|
||||
if module.params['verification_method'] == 'dns':
|
||||
if result.get('dnsMethod') and result['dnsMethod']['recordValue'] != self.dns_contents:
|
||||
break
|
||||
elif module.params['verification_method'] == 'web_server':
|
||||
if result.get('webServerMethod') and result['webServerMethod']['fileContents'] != self.file_contents:
|
||||
break
|
||||
time.sleep(10)
|
||||
result = self.ecs_client.GetDomain(clientId=module.params['client_id'], domain=module.params['domain_name'])
|
||||
self.changed = True
|
||||
self.set_domain_details(result)
|
||||
except RestOperationException as e:
|
||||
module.fail_json(msg='Failed to request domain validation from Entrust (ECS) {0}'.format(e.message))
|
||||
|
||||
def dump(self):
|
||||
result = {
|
||||
'changed': self.changed,
|
||||
'client_id': self.client_id,
|
||||
'domain_status': self.domain_status,
|
||||
}
|
||||
|
||||
if self.verification_method:
|
||||
result['verification_method'] = self.verification_method
|
||||
if self.ov_eligible is not None:
|
||||
result['ov_eligible'] = self.ov_eligible
|
||||
if self.ov_days_remaining:
|
||||
result['ov_days_remaining'] = self.ov_days_remaining
|
||||
if self.ev_eligible is not None:
|
||||
result['ev_eligible'] = self.ev_eligible
|
||||
if self.ev_days_remaining:
|
||||
result['ev_days_remaining'] = self.ev_days_remaining
|
||||
if self.emails:
|
||||
result['emails'] = self.emails
|
||||
|
||||
if self.verification_method == 'dns':
|
||||
result['dns_location'] = self.dns_location
|
||||
result['dns_contents'] = self.dns_contents
|
||||
result['dns_resource_type'] = self.dns_resource_type
|
||||
elif self.verification_method == 'web_server':
|
||||
result['file_location'] = self.file_location
|
||||
result['file_contents'] = self.file_contents
|
||||
elif self.verification_method == 'email':
|
||||
result['emails'] = self.emails
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def ecs_domain_argument_spec():
|
||||
return dict(
|
||||
client_id=dict(type='int', default=1),
|
||||
domain_name=dict(type='str', required=True),
|
||||
verification_method=dict(type='str', required=True, choices=['dns', 'email', 'manual', 'web_server']),
|
||||
verification_email=dict(type='str'),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
ecs_argument_spec = ecs_client_argument_spec()
|
||||
ecs_argument_spec.update(ecs_domain_argument_spec())
|
||||
module = AnsibleModule(
|
||||
argument_spec=ecs_argument_spec,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
if module.params['verification_email'] and module.params['verification_method'] != 'email':
|
||||
module.fail_json(msg='The verification_email field is invalid when verification_method="{0}".'.format(module.params['verification_method']))
|
||||
|
||||
domain = EcsDomain(module)
|
||||
domain.request_domain(module)
|
||||
result = domain.dump()
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
367
plugins/modules/get_certificate.py
Normal file
367
plugins/modules/get_certificate.py
Normal file
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: get_certificate
|
||||
author: "John Westcott IV (@john-westcott-iv)"
|
||||
short_description: Get a certificate from a host:port
|
||||
description:
|
||||
- Makes a secure connection and returns information about the presented certificate
|
||||
- The module can use the cryptography Python library, or the pyOpenSSL Python
|
||||
library. By default, it tries to detect which one is available. This can be
|
||||
overridden with the I(select_crypto_backend) option. Please note that the PyOpenSSL
|
||||
backend was deprecated in Ansible 2.9 and will be removed in Ansible 2.13."
|
||||
options:
|
||||
host:
|
||||
description:
|
||||
- The host to get the cert for (IP is fine)
|
||||
type: str
|
||||
required: true
|
||||
ca_cert:
|
||||
description:
|
||||
- A PEM file containing one or more root certificates; if present, the cert will be validated against these root certs.
|
||||
- Note that this only validates the certificate is signed by the chain; not that the cert is valid for the host presenting it.
|
||||
type: path
|
||||
port:
|
||||
description:
|
||||
- The port to connect to
|
||||
type: int
|
||||
required: true
|
||||
proxy_host:
|
||||
description:
|
||||
- Proxy host used when get a certificate.
|
||||
type: str
|
||||
proxy_port:
|
||||
description:
|
||||
- Proxy port used when get a certificate.
|
||||
type: int
|
||||
default: 8080
|
||||
timeout:
|
||||
description:
|
||||
- The timeout in seconds
|
||||
type: int
|
||||
default: 10
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
|
||||
notes:
|
||||
- When using ca_cert on OS X it has been reported that in some conditions the validate will always succeed.
|
||||
|
||||
requirements:
|
||||
- "python >= 2.7 when using C(proxy_host)"
|
||||
- "cryptography >= 1.6 or pyOpenSSL >= 0.15"
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
cert:
|
||||
description: The certificate retrieved from the port
|
||||
returned: success
|
||||
type: str
|
||||
expired:
|
||||
description: Boolean indicating if the cert is expired
|
||||
returned: success
|
||||
type: bool
|
||||
extensions:
|
||||
description: Extensions applied to the cert
|
||||
returned: success
|
||||
type: list
|
||||
elements: dict
|
||||
contains:
|
||||
critical:
|
||||
returned: success
|
||||
type: bool
|
||||
description: Whether the extension is critical.
|
||||
asn1_data:
|
||||
returned: success
|
||||
type: str
|
||||
description: The Base64 encoded ASN.1 content of the extnesion.
|
||||
name:
|
||||
returned: success
|
||||
type: str
|
||||
description: The extension's name.
|
||||
issuer:
|
||||
description: Information about the issuer of the cert
|
||||
returned: success
|
||||
type: dict
|
||||
not_after:
|
||||
description: Expiration date of the cert
|
||||
returned: success
|
||||
type: str
|
||||
not_before:
|
||||
description: Issue date of the cert
|
||||
returned: success
|
||||
type: str
|
||||
serial_number:
|
||||
description: The serial number of the cert
|
||||
returned: success
|
||||
type: str
|
||||
signature_algorithm:
|
||||
description: The algorithm used to sign the cert
|
||||
returned: success
|
||||
type: str
|
||||
subject:
|
||||
description: Information about the subject of the cert (OU, CN, etc)
|
||||
returned: success
|
||||
type: dict
|
||||
version:
|
||||
description: The version number of the certificate
|
||||
returned: success
|
||||
type: str
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Get the cert from an RDP port
|
||||
get_certificate:
|
||||
host: "1.2.3.4"
|
||||
port: 3389
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
register: cert
|
||||
|
||||
- name: Get a cert from an https port
|
||||
get_certificate:
|
||||
host: "www.google.com"
|
||||
port: 443
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
register: cert
|
||||
|
||||
- name: How many days until cert expires
|
||||
debug:
|
||||
msg: "cert expires in: {{ expire_days }} days."
|
||||
vars:
|
||||
expire_days: "{{ (( cert.not_after | to_datetime('%Y%m%d%H%M%SZ')) - (ansible_date_time.iso8601 | to_datetime('%Y-%m-%dT%H:%M:%SZ')) ).days }}"
|
||||
'''
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_bytes
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
|
||||
from distutils.version import LooseVersion
|
||||
from os.path import isfile
|
||||
from socket import setdefaulttimeout, socket
|
||||
from ssl import get_server_certificate, DER_cert_to_PEM_cert, CERT_NONE, CERT_OPTIONAL
|
||||
|
||||
import atexit
|
||||
import base64
|
||||
import datetime
|
||||
import traceback
|
||||
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
|
||||
|
||||
CREATE_DEFAULT_CONTEXT_IMP_ERR = None
|
||||
try:
|
||||
from ssl import create_default_context
|
||||
except ImportError:
|
||||
CREATE_DEFAULT_CONTEXT_IMP_ERR = traceback.format_exc()
|
||||
HAS_CREATE_DEFAULT_CONTEXT = False
|
||||
else:
|
||||
HAS_CREATE_DEFAULT_CONTEXT = True
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.exceptions
|
||||
import cryptography.x509
|
||||
from cryptography.hazmat.backends import default_backend as cryptography_backend
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
ca_cert=dict(type='path'),
|
||||
host=dict(type='str', required=True),
|
||||
port=dict(type='int', required=True),
|
||||
proxy_host=dict(type='str'),
|
||||
proxy_port=dict(type='int', default=8080),
|
||||
timeout=dict(type='int', default=10),
|
||||
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
|
||||
),
|
||||
)
|
||||
|
||||
ca_cert = module.params.get('ca_cert')
|
||||
host = module.params.get('host')
|
||||
port = module.params.get('port')
|
||||
proxy_host = module.params.get('proxy_host')
|
||||
proxy_port = module.params.get('proxy_port')
|
||||
timeout = module.params.get('timeout')
|
||||
|
||||
backend = module.params.get('select_crypto_backend')
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# First try cryptography, then pyOpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', version='2.13')
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
result = dict(
|
||||
changed=False,
|
||||
)
|
||||
|
||||
if timeout:
|
||||
setdefaulttimeout(timeout)
|
||||
|
||||
if ca_cert:
|
||||
if not isfile(ca_cert):
|
||||
module.fail_json(msg="ca_cert file does not exist")
|
||||
|
||||
if proxy_host:
|
||||
if not HAS_CREATE_DEFAULT_CONTEXT:
|
||||
module.fail_json(msg='To use proxy_host, you must run the get_certificate module with Python 2.7 or newer.',
|
||||
exception=CREATE_DEFAULT_CONTEXT_IMP_ERR)
|
||||
|
||||
try:
|
||||
connect = "CONNECT %s:%s HTTP/1.0\r\n\r\n" % (host, port)
|
||||
sock = socket()
|
||||
atexit.register(sock.close)
|
||||
sock.connect((proxy_host, proxy_port))
|
||||
sock.send(connect.encode())
|
||||
sock.recv(8192)
|
||||
|
||||
ctx = create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = CERT_NONE
|
||||
|
||||
if ca_cert:
|
||||
ctx.verify_mode = CERT_OPTIONAL
|
||||
ctx.load_verify_locations(cafile=ca_cert)
|
||||
|
||||
cert = ctx.wrap_socket(sock, server_hostname=host).getpeercert(True)
|
||||
cert = DER_cert_to_PEM_cert(cert)
|
||||
except Exception as e:
|
||||
module.fail_json(msg="Failed to get cert from port with error: {0}".format(e))
|
||||
|
||||
else:
|
||||
try:
|
||||
cert = get_server_certificate((host, port), ca_certs=ca_cert)
|
||||
except Exception as e:
|
||||
module.fail_json(msg="Failed to get cert from port with error: {0}".format(e))
|
||||
|
||||
result['cert'] = cert
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
|
||||
result['subject'] = {}
|
||||
for component in x509.get_subject().get_components():
|
||||
result['subject'][component[0]] = component[1]
|
||||
|
||||
result['expired'] = x509.has_expired()
|
||||
|
||||
result['extensions'] = []
|
||||
extension_count = x509.get_extension_count()
|
||||
for index in range(0, extension_count):
|
||||
extension = x509.get_extension(index)
|
||||
result['extensions'].append({
|
||||
'critical': extension.get_critical(),
|
||||
'asn1_data': extension.get_data(),
|
||||
'name': extension.get_short_name(),
|
||||
})
|
||||
|
||||
result['issuer'] = {}
|
||||
for component in x509.get_issuer().get_components():
|
||||
result['issuer'][component[0]] = component[1]
|
||||
|
||||
result['not_after'] = x509.get_notAfter()
|
||||
result['not_before'] = x509.get_notBefore()
|
||||
|
||||
result['serial_number'] = x509.get_serial_number()
|
||||
result['signature_algorithm'] = x509.get_signature_algorithm()
|
||||
|
||||
result['version'] = x509.get_version()
|
||||
|
||||
elif backend == 'cryptography':
|
||||
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography_backend())
|
||||
result['subject'] = {}
|
||||
for attribute in x509.subject:
|
||||
result['subject'][crypto_utils.cryptography_oid_to_name(attribute.oid, short=True)] = attribute.value
|
||||
|
||||
result['expired'] = x509.not_valid_after < datetime.datetime.utcnow()
|
||||
|
||||
result['extensions'] = []
|
||||
for dotted_number, entry in crypto_utils.cryptography_get_extensions_from_cert(x509).items():
|
||||
oid = cryptography.x509.oid.ObjectIdentifier(dotted_number)
|
||||
result['extensions'].append({
|
||||
'critical': entry['critical'],
|
||||
'asn1_data': base64.b64decode(entry['value']),
|
||||
'name': crypto_utils.cryptography_oid_to_name(oid, short=True),
|
||||
})
|
||||
|
||||
result['issuer'] = {}
|
||||
for attribute in x509.issuer:
|
||||
result['issuer'][crypto_utils.cryptography_oid_to_name(attribute.oid, short=True)] = attribute.value
|
||||
|
||||
result['not_after'] = x509.not_valid_after.strftime('%Y%m%d%H%M%SZ')
|
||||
result['not_before'] = x509.not_valid_before.strftime('%Y%m%d%H%M%SZ')
|
||||
|
||||
result['serial_number'] = x509.serial_number
|
||||
result['signature_algorithm'] = crypto_utils.cryptography_oid_to_name(x509.signature_algorithm_oid)
|
||||
|
||||
# We need the -1 offset to get the same values as pyOpenSSL
|
||||
if x509.version == cryptography.x509.Version.v1:
|
||||
result['version'] = 1 - 1
|
||||
elif x509.version == cryptography.x509.Version.v3:
|
||||
result['version'] = 3 - 1
|
||||
else:
|
||||
result['version'] = "unknown"
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
786
plugins/modules/luks_device.py
Normal file
786
plugins/modules/luks_device.py
Normal file
@@ -0,0 +1,786 @@
|
||||
#!/usr/bin/python
|
||||
# Copyright (c) 2017 Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: luks_device
|
||||
|
||||
short_description: Manage encrypted (LUKS) devices
|
||||
|
||||
|
||||
description:
|
||||
- "Module manages L(LUKS,https://en.wikipedia.org/wiki/Linux_Unified_Key_Setup)
|
||||
on given device. Supports creating, destroying, opening and closing of
|
||||
LUKS container and adding or removing new keys and passphrases."
|
||||
|
||||
options:
|
||||
device:
|
||||
description:
|
||||
- "Device to work with (e.g. C(/dev/sda1)). Needed in most cases.
|
||||
Can be omitted only when I(state=closed) together with I(name)
|
||||
is provided."
|
||||
type: str
|
||||
state:
|
||||
description:
|
||||
- "Desired state of the LUKS container. Based on its value creates,
|
||||
destroys, opens or closes the LUKS container on a given device."
|
||||
- "I(present) will create LUKS container unless already present.
|
||||
Requires I(device) and either I(keyfile) or I(passphrase) options
|
||||
to be provided."
|
||||
- "I(absent) will remove existing LUKS container if it exists.
|
||||
Requires I(device) or I(name) to be specified."
|
||||
- "I(opened) will unlock the LUKS container. If it does not exist
|
||||
it will be created first.
|
||||
Requires I(device) and either I(keyfile) or I(passphrase)
|
||||
to be specified. Use the I(name) option to set the name of
|
||||
the opened container. Otherwise the name will be
|
||||
generated automatically and returned as a part of the
|
||||
result."
|
||||
- "I(closed) will lock the LUKS container. However if the container
|
||||
does not exist it will be created.
|
||||
Requires I(device) and either I(keyfile) or I(passphrase)
|
||||
options to be provided. If container does already exist
|
||||
I(device) or I(name) will suffice."
|
||||
type: str
|
||||
default: present
|
||||
choices: [present, absent, opened, closed]
|
||||
name:
|
||||
description:
|
||||
- "Sets container name when I(state=opened). Can be used
|
||||
instead of I(device) when closing the existing container
|
||||
(i.e. when I(state=closed))."
|
||||
type: str
|
||||
keyfile:
|
||||
description:
|
||||
- "Used to unlock the container. Either a I(keyfile) or a
|
||||
I(passphrase) is needed for most of the operations. Parameter
|
||||
value is the path to the keyfile with the passphrase."
|
||||
- "BEWARE that working with keyfiles in plaintext is dangerous.
|
||||
Make sure that they are protected."
|
||||
type: path
|
||||
passphrase:
|
||||
description:
|
||||
- "Used to unlock the container. Either a I(passphrase) or a
|
||||
I(keyfile) is needed for most of the operations. Parameter
|
||||
value is a string with the passphrase."
|
||||
type: str
|
||||
keysize:
|
||||
description:
|
||||
- "Sets the key size only if LUKS container does not exist."
|
||||
type: int
|
||||
new_keyfile:
|
||||
description:
|
||||
- "Adds additional key to given container on I(device).
|
||||
Needs I(keyfile) or I(passphrase) option for authorization.
|
||||
LUKS container supports up to 8 keyslots. Parameter value
|
||||
is the path to the keyfile with the passphrase."
|
||||
- "NOTE that adding additional keys is *not idempotent*.
|
||||
A new keyslot will be used even if another keyslot already
|
||||
exists for this keyfile."
|
||||
- "BEWARE that working with keyfiles in plaintext is dangerous.
|
||||
Make sure that they are protected."
|
||||
type: path
|
||||
new_passphrase:
|
||||
description:
|
||||
- "Adds additional passphrase to given container on I(device).
|
||||
Needs I(keyfile) or I(passphrase) option for authorization. LUKS
|
||||
container supports up to 8 keyslots. Parameter value is a string
|
||||
with the new passphrase."
|
||||
- "NOTE that adding additional passphrase is *not idempotent*. A
|
||||
new keyslot will be used even if another keyslot already exists
|
||||
for this passphrase."
|
||||
type: str
|
||||
remove_keyfile:
|
||||
description:
|
||||
- "Removes given key from the container on I(device). Does not
|
||||
remove the keyfile from filesystem.
|
||||
Parameter value is the path to the keyfile with the passphrase."
|
||||
- "NOTE that removing keys is *not idempotent*. Trying to remove
|
||||
a key which no longer exists results in an error."
|
||||
- "NOTE that to remove the last key from a LUKS container, the
|
||||
I(force_remove_last_key) option must be set to C(yes)."
|
||||
- "BEWARE that working with keyfiles in plaintext is dangerous.
|
||||
Make sure that they are protected."
|
||||
type: path
|
||||
remove_passphrase:
|
||||
description:
|
||||
- "Removes given passphrase from the container on I(device).
|
||||
Parameter value is a string with the passphrase to remove."
|
||||
- "NOTE that removing passphrases is I(not
|
||||
idempotent). Trying to remove a passphrase which no longer
|
||||
exists results in an error."
|
||||
- "NOTE that to remove the last keyslot from a LUKS
|
||||
container, the I(force_remove_last_key) option must be set
|
||||
to C(yes)."
|
||||
type: str
|
||||
force_remove_last_key:
|
||||
description:
|
||||
- "If set to C(yes), allows removing the last key from a container."
|
||||
- "BEWARE that when the last key has been removed from a container,
|
||||
the container can no longer be opened!"
|
||||
type: bool
|
||||
default: no
|
||||
label:
|
||||
description:
|
||||
- "This option allow the user to create a LUKS2 format container
|
||||
with label support, respectively to identify the container by
|
||||
label on later usages."
|
||||
- "Will only be used on container creation, or when I(device) is
|
||||
not specified."
|
||||
- "This cannot be specified if I(type) is set to C(luks1)."
|
||||
type: str
|
||||
uuid:
|
||||
description:
|
||||
- "With this option user can identify the LUKS container by UUID."
|
||||
- "Will only be used when I(device) and I(label) are not specified."
|
||||
type: str
|
||||
type:
|
||||
description:
|
||||
- "This option allow the user explicit define the format of LUKS
|
||||
container that wants to work with. Options are C(luks1) or C(luks2)"
|
||||
type: str
|
||||
choices: [luks1, luks2]
|
||||
|
||||
|
||||
|
||||
requirements:
|
||||
- "cryptsetup"
|
||||
- "wipefs (when I(state) is C(absent))"
|
||||
- "lsblk"
|
||||
- "blkid (when I(label) or I(uuid) options are used)"
|
||||
|
||||
author: Jan Pokorny (@japokorn)
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
|
||||
- name: create LUKS container (remains unchanged if it already exists)
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "present"
|
||||
keyfile: "/vault/keyfile"
|
||||
|
||||
- name: create LUKS container with a passphrase
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "present"
|
||||
passphrase: "foo"
|
||||
|
||||
- name: (create and) open the LUKS container; name it "mycrypt"
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "opened"
|
||||
name: "mycrypt"
|
||||
keyfile: "/vault/keyfile"
|
||||
|
||||
- name: close the existing LUKS container "mycrypt"
|
||||
luks_device:
|
||||
state: "closed"
|
||||
name: "mycrypt"
|
||||
|
||||
- name: make sure LUKS container exists and is closed
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "closed"
|
||||
keyfile: "/vault/keyfile"
|
||||
|
||||
- name: create container if it does not exist and add new key to it
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "present"
|
||||
keyfile: "/vault/keyfile"
|
||||
new_keyfile: "/vault/keyfile2"
|
||||
|
||||
- name: add new key to the LUKS container (container has to exist)
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
keyfile: "/vault/keyfile"
|
||||
new_keyfile: "/vault/keyfile2"
|
||||
|
||||
- name: add new passphrase to the LUKS container
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
keyfile: "/vault/keyfile"
|
||||
new_passphrase: "foo"
|
||||
|
||||
- name: remove existing keyfile from the LUKS container
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
remove_keyfile: "/vault/keyfile2"
|
||||
|
||||
- name: remove existing passphrase from the LUKS container
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
remove_passphrase: "foo"
|
||||
|
||||
- name: completely remove the LUKS container and its contents
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "absent"
|
||||
|
||||
- name: create a container with label
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "present"
|
||||
keyfile: "/vault/keyfile"
|
||||
label: personalLabelName
|
||||
|
||||
- name: open the LUKS container based on label without device; name it "mycrypt"
|
||||
luks_device:
|
||||
label: "personalLabelName"
|
||||
state: "opened"
|
||||
name: "mycrypt"
|
||||
keyfile: "/vault/keyfile"
|
||||
|
||||
- name: close container based on UUID
|
||||
luks_device:
|
||||
uuid: 03ecd578-fad4-4e6c-9348-842e3e8fa340
|
||||
state: "closed"
|
||||
name: "mycrypt"
|
||||
|
||||
- name: create a container using luks2 format
|
||||
luks_device:
|
||||
device: "/dev/loop0"
|
||||
state: "present"
|
||||
keyfile: "/vault/keyfile"
|
||||
type: luks2
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
name:
|
||||
description:
|
||||
When I(state=opened) returns (generated or given) name
|
||||
of LUKS container. Returns None if no name is supplied.
|
||||
returned: success
|
||||
type: str
|
||||
sample: "luks-c1da9a58-2fde-4256-9d9f-6ab008b4dd1b"
|
||||
'''
|
||||
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
RETURN_CODE = 0
|
||||
STDOUT = 1
|
||||
STDERR = 2
|
||||
|
||||
# used to get <luks-name> out of lsblk output in format 'crypt <luks-name>'
|
||||
# regex takes care of any possible blank characters
|
||||
LUKS_NAME_REGEX = re.compile(r'\s*crypt\s+([^\s]*)\s*')
|
||||
# used to get </luks/device> out of lsblk output
|
||||
# in format 'device: </luks/device>'
|
||||
LUKS_DEVICE_REGEX = re.compile(r'\s*device:\s+([^\s]*)\s*')
|
||||
|
||||
|
||||
class Handler(object):
|
||||
|
||||
def __init__(self, module):
|
||||
self._module = module
|
||||
self._lsblk_bin = self._module.get_bin_path('lsblk', True)
|
||||
|
||||
def _run_command(self, command, data=None):
|
||||
return self._module.run_command(command, data=data)
|
||||
|
||||
def get_device_by_uuid(self, uuid):
|
||||
''' Returns the device that holds UUID passed by user
|
||||
'''
|
||||
self._blkid_bin = self._module.get_bin_path('blkid', True)
|
||||
uuid = self._module.params['uuid']
|
||||
if uuid is None:
|
||||
return None
|
||||
result = self._run_command([self._blkid_bin, '--uuid', uuid])
|
||||
if result[RETURN_CODE] != 0:
|
||||
return None
|
||||
return result[STDOUT].strip()
|
||||
|
||||
def get_device_by_label(self, label):
|
||||
''' Returns the device that holds label passed by user
|
||||
'''
|
||||
self._blkid_bin = self._module.get_bin_path('blkid', True)
|
||||
label = self._module.params['label']
|
||||
if label is None:
|
||||
return None
|
||||
result = self._run_command([self._blkid_bin, '--label', label])
|
||||
if result[RETURN_CODE] != 0:
|
||||
return None
|
||||
return result[STDOUT].strip()
|
||||
|
||||
def generate_luks_name(self, device):
|
||||
''' Generate name for luks based on device UUID ('luks-<UUID>').
|
||||
Raises ValueError when obtaining of UUID fails.
|
||||
'''
|
||||
result = self._run_command([self._lsblk_bin, '-n', device, '-o', 'UUID'])
|
||||
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while generating LUKS name for %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
dev_uuid = result[STDOUT].strip()
|
||||
return 'luks-%s' % dev_uuid
|
||||
|
||||
|
||||
class CryptHandler(Handler):
|
||||
|
||||
def __init__(self, module):
|
||||
super(CryptHandler, self).__init__(module)
|
||||
self._cryptsetup_bin = self._module.get_bin_path('cryptsetup', True)
|
||||
|
||||
def get_container_name_by_device(self, device):
|
||||
''' obtain LUKS container name based on the device where it is located
|
||||
return None if not found
|
||||
raise ValueError if lsblk command fails
|
||||
'''
|
||||
result = self._run_command([self._lsblk_bin, device, '-nlo', 'type,name'])
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while obtaining LUKS name for %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
|
||||
m = LUKS_NAME_REGEX.search(result[STDOUT])
|
||||
|
||||
try:
|
||||
name = m.group(1)
|
||||
except AttributeError:
|
||||
name = None
|
||||
return name
|
||||
|
||||
def get_container_device_by_name(self, name):
|
||||
''' obtain device name based on the LUKS container name
|
||||
return None if not found
|
||||
raise ValueError if lsblk command fails
|
||||
'''
|
||||
# apparently each device can have only one LUKS container on it
|
||||
result = self._run_command([self._cryptsetup_bin, 'status', name])
|
||||
if result[RETURN_CODE] != 0:
|
||||
return None
|
||||
|
||||
m = LUKS_DEVICE_REGEX.search(result[STDOUT])
|
||||
device = m.group(1)
|
||||
return device
|
||||
|
||||
def is_luks(self, device):
|
||||
''' check if the LUKS container does exist
|
||||
'''
|
||||
result = self._run_command([self._cryptsetup_bin, 'isLuks', device])
|
||||
return result[RETURN_CODE] == 0
|
||||
|
||||
def run_luks_create(self, device, keyfile, passphrase, keysize):
|
||||
# create a new luks container; use batch mode to auto confirm
|
||||
luks_type = self._module.params['type']
|
||||
label = self._module.params['label']
|
||||
|
||||
options = []
|
||||
if keysize is not None:
|
||||
options.append('--key-size=' + str(keysize))
|
||||
if label is not None:
|
||||
options.extend(['--label', label])
|
||||
luks_type = 'luks2'
|
||||
if luks_type is not None:
|
||||
options.extend(['--type', luks_type])
|
||||
|
||||
args = [self._cryptsetup_bin, 'luksFormat']
|
||||
args.extend(options)
|
||||
args.extend(['-q', device])
|
||||
if keyfile:
|
||||
args.append(keyfile)
|
||||
|
||||
result = self._run_command(args, data=passphrase)
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while creating LUKS on %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
|
||||
def run_luks_open(self, device, keyfile, passphrase, name):
|
||||
args = [self._cryptsetup_bin]
|
||||
if keyfile:
|
||||
args.extend(['--key-file', keyfile])
|
||||
args.extend(['open', '--type', 'luks', device, name])
|
||||
|
||||
result = self._run_command(args, data=passphrase)
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while opening LUKS container on %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
|
||||
def run_luks_close(self, name):
|
||||
result = self._run_command([self._cryptsetup_bin, 'close', name])
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while closing LUKS container %s' % (name))
|
||||
|
||||
def run_luks_remove(self, device):
|
||||
wipefs_bin = self._module.get_bin_path('wipefs', True)
|
||||
|
||||
name = self.get_container_name_by_device(device)
|
||||
if name is not None:
|
||||
self.run_luks_close(name)
|
||||
result = self._run_command([wipefs_bin, '--all', device])
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while wiping luks container %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
|
||||
def run_luks_add_key(self, device, keyfile, passphrase, new_keyfile,
|
||||
new_passphrase):
|
||||
''' Add new key from a keyfile or passphrase to given 'device';
|
||||
authentication done using 'keyfile' or 'passphrase'.
|
||||
Raises ValueError when command fails.
|
||||
'''
|
||||
data = []
|
||||
args = [self._cryptsetup_bin, 'luksAddKey', device]
|
||||
|
||||
if keyfile:
|
||||
args.extend(['--key-file', keyfile])
|
||||
else:
|
||||
data.append(passphrase)
|
||||
|
||||
if new_keyfile:
|
||||
args.append(new_keyfile)
|
||||
else:
|
||||
data.extend([new_passphrase, new_passphrase])
|
||||
|
||||
result = self._run_command(args, data='\n'.join(data) or None)
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while adding new LUKS keyslot to %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
|
||||
def run_luks_remove_key(self, device, keyfile, passphrase,
|
||||
force_remove_last_key=False):
|
||||
''' Remove key from given device
|
||||
Raises ValueError when command fails
|
||||
'''
|
||||
if not force_remove_last_key:
|
||||
result = self._run_command([self._cryptsetup_bin, 'luksDump', device])
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while dumping LUKS header from %s'
|
||||
% (device, ))
|
||||
keyslot_count = 0
|
||||
keyslot_area = False
|
||||
keyslot_re = re.compile(r'^Key Slot [0-9]+: ENABLED')
|
||||
for line in result[STDOUT].splitlines():
|
||||
if line.startswith('Keyslots:'):
|
||||
keyslot_area = True
|
||||
elif line.startswith(' '):
|
||||
# LUKS2 header dumps use human-readable indented output.
|
||||
# Thus we have to look out for 'Keyslots:' and count the
|
||||
# number of indented keyslot numbers.
|
||||
if keyslot_area and line[2] in '0123456789':
|
||||
keyslot_count += 1
|
||||
elif line.startswith('\t'):
|
||||
pass
|
||||
elif keyslot_re.match(line):
|
||||
# LUKS1 header dumps have one line per keyslot with ENABLED
|
||||
# or DISABLED in them. We count such lines with ENABLED.
|
||||
keyslot_count += 1
|
||||
else:
|
||||
keyslot_area = False
|
||||
if keyslot_count < 2:
|
||||
self._module.fail_json(msg="LUKS device %s has less than two active keyslots. "
|
||||
"To be able to remove a key, please set "
|
||||
"`force_remove_last_key` to `yes`." % device)
|
||||
|
||||
args = [self._cryptsetup_bin, 'luksRemoveKey', device, '-q']
|
||||
if keyfile:
|
||||
args.extend(['--key-file', keyfile])
|
||||
result = self._run_command(args, data=passphrase)
|
||||
if result[RETURN_CODE] != 0:
|
||||
raise ValueError('Error while removing LUKS key from %s: %s'
|
||||
% (device, result[STDERR]))
|
||||
|
||||
|
||||
class ConditionsHandler(Handler):
|
||||
|
||||
def __init__(self, module, crypthandler):
|
||||
super(ConditionsHandler, self).__init__(module)
|
||||
self._crypthandler = crypthandler
|
||||
self.device = self.get_device_name()
|
||||
|
||||
def get_device_name(self):
|
||||
device = self._module.params.get('device')
|
||||
label = self._module.params.get('label')
|
||||
uuid = self._module.params.get('uuid')
|
||||
name = self._module.params.get('name')
|
||||
|
||||
if device is None and label is not None:
|
||||
device = self.get_device_by_label(label)
|
||||
elif device is None and uuid is not None:
|
||||
device = self.get_device_by_uuid(uuid)
|
||||
elif device is None and name is not None:
|
||||
device = self._crypthandler.get_container_device_by_name(name)
|
||||
|
||||
return device
|
||||
|
||||
def luks_create(self):
|
||||
return (self.device is not None and
|
||||
(self._module.params['keyfile'] is not None or
|
||||
self._module.params['passphrase'] is not None) and
|
||||
self._module.params['state'] in ('present',
|
||||
'opened',
|
||||
'closed') and
|
||||
not self._crypthandler.is_luks(self.device))
|
||||
|
||||
def opened_luks_name(self):
|
||||
''' If luks is already opened, return its name.
|
||||
If 'name' parameter is specified and differs
|
||||
from obtained value, fail.
|
||||
Return None otherwise
|
||||
'''
|
||||
if self._module.params['state'] != 'opened':
|
||||
return None
|
||||
|
||||
# try to obtain luks name - it may be already opened
|
||||
name = self._crypthandler.get_container_name_by_device(self.device)
|
||||
|
||||
if name is None:
|
||||
# container is not open
|
||||
return None
|
||||
|
||||
if self._module.params['name'] is None:
|
||||
# container is already opened
|
||||
return name
|
||||
|
||||
if name != self._module.params['name']:
|
||||
# the container is already open but with different name:
|
||||
# suspicious. back off
|
||||
self._module.fail_json(msg="LUKS container is already opened "
|
||||
"under different name '%s'." % name)
|
||||
|
||||
# container is opened and the names match
|
||||
return name
|
||||
|
||||
def luks_open(self):
|
||||
if ((self._module.params['keyfile'] is None and
|
||||
self._module.params['passphrase'] is None) or
|
||||
self.device is None or
|
||||
self._module.params['state'] != 'opened'):
|
||||
# conditions for open not fulfilled
|
||||
return False
|
||||
|
||||
name = self.opened_luks_name()
|
||||
|
||||
if name is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
def luks_close(self):
|
||||
if ((self._module.params['name'] is None and self.device is None) or
|
||||
self._module.params['state'] != 'closed'):
|
||||
# conditions for close not fulfilled
|
||||
return False
|
||||
|
||||
if self.device is not None:
|
||||
name = self._crypthandler.get_container_name_by_device(self.device)
|
||||
# successfully getting name based on device means that luks is open
|
||||
luks_is_open = name is not None
|
||||
|
||||
if self._module.params['name'] is not None:
|
||||
self.device = self._crypthandler.get_container_device_by_name(
|
||||
self._module.params['name'])
|
||||
# successfully getting device based on name means that luks is open
|
||||
luks_is_open = self.device is not None
|
||||
|
||||
return luks_is_open
|
||||
|
||||
def luks_add_key(self):
|
||||
if (self.device is None or
|
||||
(self._module.params['keyfile'] is None and
|
||||
self._module.params['passphrase'] is None) or
|
||||
(self._module.params['new_keyfile'] is None and
|
||||
self._module.params['new_passphrase'] is None)):
|
||||
# conditions for adding a key not fulfilled
|
||||
return False
|
||||
|
||||
if self._module.params['state'] == 'absent':
|
||||
self._module.fail_json(msg="Contradiction in setup: Asking to "
|
||||
"add a key to absent LUKS.")
|
||||
|
||||
return True
|
||||
|
||||
def luks_remove_key(self):
|
||||
if (self.device is None or
|
||||
(self._module.params['remove_keyfile'] is None and
|
||||
self._module.params['remove_passphrase'] is None)):
|
||||
# conditions for removing a key not fulfilled
|
||||
return False
|
||||
|
||||
if self._module.params['state'] == 'absent':
|
||||
self._module.fail_json(msg="Contradiction in setup: Asking to "
|
||||
"remove a key from absent LUKS.")
|
||||
|
||||
return True
|
||||
|
||||
def luks_remove(self):
|
||||
return (self.device is not None and
|
||||
self._module.params['state'] == 'absent' and
|
||||
self._crypthandler.is_luks(self.device))
|
||||
|
||||
|
||||
def run_module():
|
||||
# available arguments/parameters that a user can pass
|
||||
module_args = dict(
|
||||
state=dict(type='str', default='present', choices=['present', 'absent', 'opened', 'closed']),
|
||||
device=dict(type='str'),
|
||||
name=dict(type='str'),
|
||||
keyfile=dict(type='path'),
|
||||
new_keyfile=dict(type='path'),
|
||||
remove_keyfile=dict(type='path'),
|
||||
passphrase=dict(type='str', no_log=True),
|
||||
new_passphrase=dict(type='str', no_log=True),
|
||||
remove_passphrase=dict(type='str', no_log=True),
|
||||
force_remove_last_key=dict(type='bool', default=False),
|
||||
keysize=dict(type='int'),
|
||||
label=dict(type='str'),
|
||||
uuid=dict(type='str'),
|
||||
type=dict(type='str', choices=['luks1', 'luks2']),
|
||||
)
|
||||
|
||||
mutually_exclusive = [
|
||||
('keyfile', 'passphrase'),
|
||||
('new_keyfile', 'new_passphrase'),
|
||||
('remove_keyfile', 'remove_passphrase')
|
||||
]
|
||||
|
||||
# seed the result dict in the object
|
||||
result = dict(
|
||||
changed=False,
|
||||
name=None
|
||||
)
|
||||
|
||||
module = AnsibleModule(argument_spec=module_args,
|
||||
supports_check_mode=True,
|
||||
mutually_exclusive=mutually_exclusive)
|
||||
|
||||
if module.params['device'] is not None:
|
||||
try:
|
||||
statinfo = os.stat(module.params['device'])
|
||||
mode = statinfo.st_mode
|
||||
if not stat.S_ISBLK(mode) and not stat.S_ISCHR(mode):
|
||||
raise Exception('{0} is not a device'.format(module.params['device']))
|
||||
except Exception as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
crypt = CryptHandler(module)
|
||||
conditions = ConditionsHandler(module, crypt)
|
||||
|
||||
# conditions not allowed to run
|
||||
if module.params['label'] is not None and module.params['type'] == 'luks1':
|
||||
module.fail_json(msg='You cannot combine type luks1 with the label option.')
|
||||
|
||||
# The conditions are in order to allow more operations in one run.
|
||||
# (e.g. create luks and add a key to it)
|
||||
|
||||
# luks create
|
||||
if conditions.luks_create():
|
||||
if not module.check_mode:
|
||||
try:
|
||||
crypt.run_luks_create(conditions.device,
|
||||
module.params['keyfile'],
|
||||
module.params['passphrase'],
|
||||
module.params['keysize'])
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
result['changed'] = True
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
# luks open
|
||||
|
||||
name = conditions.opened_luks_name()
|
||||
if name is not None:
|
||||
result['name'] = name
|
||||
|
||||
if conditions.luks_open():
|
||||
name = module.params['name']
|
||||
if name is None:
|
||||
try:
|
||||
name = crypt.generate_luks_name(conditions.device)
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
if not module.check_mode:
|
||||
try:
|
||||
crypt.run_luks_open(conditions.device,
|
||||
module.params['keyfile'],
|
||||
module.params['passphrase'],
|
||||
name)
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
result['name'] = name
|
||||
result['changed'] = True
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
# luks close
|
||||
if conditions.luks_close():
|
||||
if conditions.device is not None:
|
||||
try:
|
||||
name = crypt.get_container_name_by_device(
|
||||
conditions.device)
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
else:
|
||||
name = module.params['name']
|
||||
if not module.check_mode:
|
||||
try:
|
||||
crypt.run_luks_close(name)
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
result['name'] = name
|
||||
result['changed'] = True
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
# luks add key
|
||||
if conditions.luks_add_key():
|
||||
if not module.check_mode:
|
||||
try:
|
||||
crypt.run_luks_add_key(conditions.device,
|
||||
module.params['keyfile'],
|
||||
module.params['passphrase'],
|
||||
module.params['new_keyfile'],
|
||||
module.params['new_passphrase'])
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
result['changed'] = True
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
# luks remove key
|
||||
if conditions.luks_remove_key():
|
||||
if not module.check_mode:
|
||||
try:
|
||||
last_key = module.params['force_remove_last_key']
|
||||
crypt.run_luks_remove_key(conditions.device,
|
||||
module.params['remove_keyfile'],
|
||||
module.params['remove_passphrase'],
|
||||
force_remove_last_key=last_key)
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
result['changed'] = True
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
# luks remove
|
||||
if conditions.luks_remove():
|
||||
if not module.check_mode:
|
||||
try:
|
||||
crypt.run_luks_remove(conditions.device)
|
||||
except ValueError as e:
|
||||
module.fail_json(msg="luks_device error: %s" % e)
|
||||
result['changed'] = True
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
# Success - return result
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
def main():
|
||||
run_module()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
589
plugins/modules/openssh_cert.py
Normal file
589
plugins/modules/openssh_cert.py
Normal file
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {
|
||||
'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'
|
||||
}
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: openssh_cert
|
||||
author: "David Kainz (@lolcube)"
|
||||
short_description: Generate OpenSSH host or user certificates.
|
||||
description:
|
||||
- Generate and regenerate OpenSSH host or user certificates.
|
||||
requirements:
|
||||
- "ssh-keygen"
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the host or user certificate should exist or not, taking action if the state is different from what is stated.
|
||||
type: str
|
||||
default: "present"
|
||||
choices: [ 'present', 'absent' ]
|
||||
type:
|
||||
description:
|
||||
- Whether the module should generate a host or a user certificate.
|
||||
- Required if I(state) is C(present).
|
||||
type: str
|
||||
choices: ['host', 'user']
|
||||
force:
|
||||
description:
|
||||
- Should the certificate be regenerated even if it already exists and is valid.
|
||||
type: bool
|
||||
default: false
|
||||
path:
|
||||
description:
|
||||
- Path of the file containing the certificate.
|
||||
type: path
|
||||
required: true
|
||||
signing_key:
|
||||
description:
|
||||
- The path to the private openssh key that is used for signing the public key in order to generate the certificate.
|
||||
- Required if I(state) is C(present).
|
||||
type: path
|
||||
public_key:
|
||||
description:
|
||||
- The path to the public key that will be signed with the signing key in order to generate the certificate.
|
||||
- Required if I(state) is C(present).
|
||||
type: path
|
||||
valid_from:
|
||||
description:
|
||||
- "The point in time the certificate is valid from. Time can be specified either as relative time or as absolute timestamp.
|
||||
Time will always be interpreted as UTC. Valid formats are: C([+-]timespec | YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS | YYYY-MM-DD HH:MM:SS | always)
|
||||
where timespec can be an integer + C([w | d | h | m | s]) (e.g. C(+32w1d2h).
|
||||
Note that if using relative time this module is NOT idempotent."
|
||||
- Required if I(state) is C(present).
|
||||
type: str
|
||||
valid_to:
|
||||
description:
|
||||
- "The point in time the certificate is valid to. Time can be specified either as relative time or as absolute timestamp.
|
||||
Time will always be interpreted as UTC. Valid formats are: C([+-]timespec | YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS | YYYY-MM-DD HH:MM:SS | forever)
|
||||
where timespec can be an integer + C([w | d | h | m | s]) (e.g. C(+32w1d2h).
|
||||
Note that if using relative time this module is NOT idempotent."
|
||||
- Required if I(state) is C(present).
|
||||
type: str
|
||||
valid_at:
|
||||
description:
|
||||
- "Check if the certificate is valid at a certain point in time. If it is not the certificate will be regenerated.
|
||||
Time will always be interpreted as UTC. Mainly to be used with relative timespec for I(valid_from) and / or I(valid_to).
|
||||
Note that if using relative time this module is NOT idempotent."
|
||||
type: str
|
||||
principals:
|
||||
description:
|
||||
- "Certificates may be limited to be valid for a set of principal (user/host) names.
|
||||
By default, generated certificates are valid for all users or hosts."
|
||||
type: list
|
||||
elements: str
|
||||
options:
|
||||
description:
|
||||
- "Specify certificate options when signing a key. The option that are valid for user certificates are:"
|
||||
- "C(clear): Clear all enabled permissions. This is useful for clearing the default set of permissions so permissions may be added individually."
|
||||
- "C(force-command=command): Forces the execution of command instead of any shell or
|
||||
command specified by the user when the certificate is used for authentication."
|
||||
- "C(no-agent-forwarding): Disable ssh-agent forwarding (permitted by default)."
|
||||
- "C(no-port-forwarding): Disable port forwarding (permitted by default)."
|
||||
- "C(no-pty Disable): PTY allocation (permitted by default)."
|
||||
- "C(no-user-rc): Disable execution of C(~/.ssh/rc) by sshd (permitted by default)."
|
||||
- "C(no-x11-forwarding): Disable X11 forwarding (permitted by default)"
|
||||
- "C(permit-agent-forwarding): Allows ssh-agent forwarding."
|
||||
- "C(permit-port-forwarding): Allows port forwarding."
|
||||
- "C(permit-pty): Allows PTY allocation."
|
||||
- "C(permit-user-rc): Allows execution of C(~/.ssh/rc) by sshd."
|
||||
- "C(permit-x11-forwarding): Allows X11 forwarding."
|
||||
- "C(source-address=address_list): Restrict the source addresses from which the certificate is considered valid.
|
||||
The C(address_list) is a comma-separated list of one or more address/netmask pairs in CIDR format."
|
||||
- "At present, no options are valid for host keys."
|
||||
type: list
|
||||
elements: str
|
||||
identifier:
|
||||
description:
|
||||
- Specify the key identity when signing a public key. The identifier that is logged by the server when the certificate is used for authentication.
|
||||
type: str
|
||||
serial_number:
|
||||
description:
|
||||
- "Specify the certificate serial number.
|
||||
The serial number is logged by the server when the certificate is used for authentication.
|
||||
The certificate serial number may be used in a KeyRevocationList.
|
||||
The serial number may be omitted for checks, but must be specified again for a new certificate.
|
||||
Note: The default value set by ssh-keygen is 0."
|
||||
type: int
|
||||
|
||||
extends_documentation_fragment: files
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
# Generate an OpenSSH user certificate that is valid forever and for all users
|
||||
- openssh_cert:
|
||||
type: user
|
||||
signing_key: /path/to/private_key
|
||||
public_key: /path/to/public_key.pub
|
||||
path: /path/to/certificate
|
||||
valid_from: always
|
||||
valid_to: forever
|
||||
|
||||
# Generate an OpenSSH host certificate that is valid for 32 weeks from now and will be regenerated
|
||||
# if it is valid for less than 2 weeks from the time the module is being run
|
||||
- openssh_cert:
|
||||
type: host
|
||||
signing_key: /path/to/private_key
|
||||
public_key: /path/to/public_key.pub
|
||||
path: /path/to/certificate
|
||||
valid_from: +0s
|
||||
valid_to: +32w
|
||||
valid_at: +2w
|
||||
|
||||
# Generate an OpenSSH host certificate that is valid forever and only for example.com and examplehost
|
||||
- openssh_cert:
|
||||
type: host
|
||||
signing_key: /path/to/private_key
|
||||
public_key: /path/to/public_key.pub
|
||||
path: /path/to/certificate
|
||||
valid_from: always
|
||||
valid_to: forever
|
||||
principals:
|
||||
- example.com
|
||||
- examplehost
|
||||
|
||||
# Generate an OpenSSH host Certificate that is valid from 21.1.2001 to 21.1.2019
|
||||
- openssh_cert:
|
||||
type: host
|
||||
signing_key: /path/to/private_key
|
||||
public_key: /path/to/public_key.pub
|
||||
path: /path/to/certificate
|
||||
valid_from: "2001-01-21"
|
||||
valid_to: "2019-01-21"
|
||||
|
||||
# Generate an OpenSSH user Certificate with clear and force-command option:
|
||||
- openssh_cert:
|
||||
type: user
|
||||
signing_key: /path/to/private_key
|
||||
public_key: /path/to/public_key.pub
|
||||
path: /path/to/certificate
|
||||
valid_from: always
|
||||
valid_to: forever
|
||||
options:
|
||||
- "clear"
|
||||
- "force-command=/tmp/bla/foo"
|
||||
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
type:
|
||||
description: type of the certificate (host or user)
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: host
|
||||
filename:
|
||||
description: path to the certificate
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /tmp/certificate-cert.pub
|
||||
info:
|
||||
description: Information about the certificate. Output of C(ssh-keygen -L -f).
|
||||
returned: change or success
|
||||
type: list
|
||||
elements: str
|
||||
|
||||
'''
|
||||
|
||||
import os
|
||||
import errno
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import MINYEAR, MAXYEAR
|
||||
from shutil import copy2
|
||||
from shutil import rmtree
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto import convert_relative_to_datetime
|
||||
from ansible.module_utils._text import to_native
|
||||
|
||||
|
||||
class CertificateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Certificate(object):
|
||||
|
||||
def __init__(self, module):
|
||||
self.state = module.params['state']
|
||||
self.force = module.params['force']
|
||||
self.type = module.params['type']
|
||||
self.signing_key = module.params['signing_key']
|
||||
self.public_key = module.params['public_key']
|
||||
self.path = module.params['path']
|
||||
self.identifier = module.params['identifier']
|
||||
self.serial_number = module.params['serial_number']
|
||||
self.valid_from = module.params['valid_from']
|
||||
self.valid_to = module.params['valid_to']
|
||||
self.valid_at = module.params['valid_at']
|
||||
self.principals = module.params['principals']
|
||||
self.options = module.params['options']
|
||||
self.changed = False
|
||||
self.check_mode = module.check_mode
|
||||
self.cert_info = {}
|
||||
|
||||
if self.state == 'present':
|
||||
|
||||
if self.options and self.type == "host":
|
||||
module.fail_json(msg="Options can only be used with user certificates.")
|
||||
|
||||
if self.valid_at:
|
||||
self.valid_at = self.valid_at.lstrip()
|
||||
|
||||
self.valid_from = self.valid_from.lstrip()
|
||||
self.valid_to = self.valid_to.lstrip()
|
||||
|
||||
self.ssh_keygen = module.get_bin_path('ssh-keygen', True)
|
||||
|
||||
def generate(self, module):
|
||||
|
||||
if not self.is_valid(module, perms_required=False) or self.force:
|
||||
args = [
|
||||
self.ssh_keygen,
|
||||
'-s', self.signing_key
|
||||
]
|
||||
|
||||
validity = ""
|
||||
|
||||
if not (self.valid_from == "always" and self.valid_to == "forever"):
|
||||
|
||||
if not self.valid_from == "always":
|
||||
timeobj = self.convert_to_datetime(module, self.valid_from)
|
||||
validity += (
|
||||
str(timeobj.year).zfill(4) +
|
||||
str(timeobj.month).zfill(2) +
|
||||
str(timeobj.day).zfill(2) +
|
||||
str(timeobj.hour).zfill(2) +
|
||||
str(timeobj.minute).zfill(2) +
|
||||
str(timeobj.second).zfill(2)
|
||||
)
|
||||
else:
|
||||
validity += "19700101010101"
|
||||
|
||||
validity += ":"
|
||||
|
||||
if self.valid_to == "forever":
|
||||
# on ssh-keygen versions that have the year 2038 bug this will cause the datetime to be 2038-01-19T04:14:07
|
||||
timeobj = datetime(MAXYEAR, 12, 31)
|
||||
else:
|
||||
timeobj = self.convert_to_datetime(module, self.valid_to)
|
||||
|
||||
validity += (
|
||||
str(timeobj.year).zfill(4) +
|
||||
str(timeobj.month).zfill(2) +
|
||||
str(timeobj.day).zfill(2) +
|
||||
str(timeobj.hour).zfill(2) +
|
||||
str(timeobj.minute).zfill(2) +
|
||||
str(timeobj.second).zfill(2)
|
||||
)
|
||||
|
||||
args.extend(["-V", validity])
|
||||
|
||||
if self.type == 'host':
|
||||
args.extend(['-h'])
|
||||
|
||||
if self.identifier:
|
||||
args.extend(['-I', self.identifier])
|
||||
else:
|
||||
args.extend(['-I', ""])
|
||||
|
||||
if self.serial_number is not None:
|
||||
args.extend(['-z', str(self.serial_number)])
|
||||
|
||||
if self.principals:
|
||||
args.extend(['-n', ','.join(self.principals)])
|
||||
|
||||
if self.options:
|
||||
for option in self.options:
|
||||
args.extend(['-O'])
|
||||
args.extend([option])
|
||||
|
||||
args.extend(['-P', ''])
|
||||
|
||||
try:
|
||||
temp_directory = tempfile.mkdtemp()
|
||||
copy2(self.public_key, temp_directory)
|
||||
args.extend([temp_directory + "/" + os.path.basename(self.public_key)])
|
||||
module.run_command(args, environ_update=dict(TZ="UTC"), check_rc=True)
|
||||
copy2(temp_directory + "/" + os.path.splitext(os.path.basename(self.public_key))[0] + "-cert.pub", self.path)
|
||||
rmtree(temp_directory, ignore_errors=True)
|
||||
proc = module.run_command([self.ssh_keygen, '-L', '-f', self.path])
|
||||
self.cert_info = proc[1].split()
|
||||
self.changed = True
|
||||
except Exception as e:
|
||||
try:
|
||||
self.remove()
|
||||
rmtree(temp_directory, ignore_errors=True)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.ENOENT:
|
||||
raise CertificateError(exc)
|
||||
else:
|
||||
pass
|
||||
module.fail_json(msg="%s" % to_native(e))
|
||||
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
if module.set_fs_attributes_if_different(file_args, False):
|
||||
self.changed = True
|
||||
|
||||
def convert_to_datetime(self, module, timestring):
|
||||
|
||||
if self.is_relative(timestring):
|
||||
result = convert_relative_to_datetime(timestring)
|
||||
if result is None:
|
||||
module.fail_json(
|
||||
msg="'%s' is not a valid time format." % timestring)
|
||||
else:
|
||||
return result
|
||||
else:
|
||||
formats = ["%Y-%m-%d",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
]
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(timestring, fmt)
|
||||
except ValueError:
|
||||
pass
|
||||
module.fail_json(msg="'%s' is not a valid time format" % timestring)
|
||||
|
||||
def is_relative(self, timestr):
|
||||
if timestr.startswith("+") or timestr.startswith("-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_same_datetime(self, datetime_one, datetime_two):
|
||||
|
||||
# This function is for backwards compatibility only because .total_seconds() is new in python2.7
|
||||
def timedelta_total_seconds(time_delta):
|
||||
return (time_delta.microseconds + 0.0 + (time_delta.seconds + time_delta.days * 24 * 3600) * 10 ** 6) / 10 ** 6
|
||||
# try to use .total_ seconds() from python2.7
|
||||
try:
|
||||
return (datetime_one - datetime_two).total_seconds() == 0.0
|
||||
except AttributeError:
|
||||
return timedelta_total_seconds(datetime_one - datetime_two) == 0.0
|
||||
|
||||
def is_valid(self, module, perms_required=True):
|
||||
|
||||
def _check_state():
|
||||
return os.path.exists(self.path)
|
||||
|
||||
if _check_state():
|
||||
proc = module.run_command([self.ssh_keygen, '-L', '-f', self.path], environ_update=dict(TZ="UTC"), check_rc=False)
|
||||
if proc[0] != 0:
|
||||
return False
|
||||
self.cert_info = proc[1].split()
|
||||
principals = re.findall("(?<=Principals:)(.*)(?=Critical)", proc[1], re.S)[0].split()
|
||||
principals = list(map(str.strip, principals))
|
||||
if principals == ["(none)"]:
|
||||
principals = None
|
||||
cert_type = re.findall("( user | host )", proc[1])[0].strip()
|
||||
serial_number = re.search(r"Serial: (\d+)", proc[1]).group(1)
|
||||
validity = re.findall("(from (\\d{4}-\\d{2}-\\d{2}T\\d{2}(:\\d{2}){2}) to (\\d{4}-\\d{2}-\\d{2}T\\d{2}(:\\d{2}){2}))", proc[1])
|
||||
if validity:
|
||||
if validity[0][1]:
|
||||
cert_valid_from = self.convert_to_datetime(module, validity[0][1])
|
||||
if self.is_same_datetime(cert_valid_from, self.convert_to_datetime(module, "1970-01-01 01:01:01")):
|
||||
cert_valid_from = datetime(MINYEAR, 1, 1)
|
||||
else:
|
||||
cert_valid_from = datetime(MINYEAR, 1, 1)
|
||||
|
||||
if validity[0][3]:
|
||||
cert_valid_to = self.convert_to_datetime(module, validity[0][3])
|
||||
if self.is_same_datetime(cert_valid_to, self.convert_to_datetime(module, "2038-01-19 03:14:07")):
|
||||
cert_valid_to = datetime(MAXYEAR, 12, 31)
|
||||
else:
|
||||
cert_valid_to = datetime(MAXYEAR, 12, 31)
|
||||
else:
|
||||
cert_valid_from = datetime(MINYEAR, 1, 1)
|
||||
cert_valid_to = datetime(MAXYEAR, 12, 31)
|
||||
else:
|
||||
return False
|
||||
|
||||
def _check_perms(module):
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
return not module.set_fs_attributes_if_different(file_args, False)
|
||||
|
||||
def _check_serial_number():
|
||||
if self.serial_number is None:
|
||||
return True
|
||||
return self.serial_number == int(serial_number)
|
||||
|
||||
def _check_type():
|
||||
return self.type == cert_type
|
||||
|
||||
def _check_principals():
|
||||
if not principals or not self.principals:
|
||||
return self.principals == principals
|
||||
return set(self.principals) == set(principals)
|
||||
|
||||
def _check_validity(module):
|
||||
if self.valid_from == "always":
|
||||
earliest_time = datetime(MINYEAR, 1, 1)
|
||||
elif self.is_relative(self.valid_from):
|
||||
earliest_time = None
|
||||
else:
|
||||
earliest_time = self.convert_to_datetime(module, self.valid_from)
|
||||
|
||||
if self.valid_to == "forever":
|
||||
last_time = datetime(MAXYEAR, 12, 31)
|
||||
elif self.is_relative(self.valid_to):
|
||||
last_time = None
|
||||
else:
|
||||
last_time = self.convert_to_datetime(module, self.valid_to)
|
||||
|
||||
if earliest_time:
|
||||
if not self.is_same_datetime(earliest_time, cert_valid_from):
|
||||
return False
|
||||
if last_time:
|
||||
if not self.is_same_datetime(last_time, cert_valid_to):
|
||||
return False
|
||||
|
||||
if self.valid_at:
|
||||
if cert_valid_from <= self.convert_to_datetime(module, self.valid_at) <= cert_valid_to:
|
||||
return True
|
||||
|
||||
if earliest_time and last_time:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
if perms_required and not _check_perms(module):
|
||||
return False
|
||||
|
||||
return _check_type() and _check_principals() and _check_validity(module) and _check_serial_number()
|
||||
|
||||
def dump(self):
|
||||
|
||||
"""Serialize the object into a dictionary."""
|
||||
|
||||
def filter_keywords(arr, keywords):
|
||||
concated = []
|
||||
string = ""
|
||||
for word in arr:
|
||||
if word in keywords:
|
||||
concated.append(string)
|
||||
string = word
|
||||
else:
|
||||
string += " " + word
|
||||
concated.append(string)
|
||||
# drop the certificate path
|
||||
concated.pop(0)
|
||||
return concated
|
||||
|
||||
def format_cert_info():
|
||||
return filter_keywords(self.cert_info, [
|
||||
"Type:",
|
||||
"Public",
|
||||
"Signing",
|
||||
"Key",
|
||||
"Serial:",
|
||||
"Valid:",
|
||||
"Principals:",
|
||||
"Critical",
|
||||
"Extensions:"])
|
||||
|
||||
if self.state == 'present':
|
||||
result = {
|
||||
'changed': self.changed,
|
||||
'type': self.type,
|
||||
'filename': self.path,
|
||||
'info': format_cert_info(),
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
'changed': self.changed,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def remove(self):
|
||||
"""Remove the resource from the filesystem."""
|
||||
|
||||
try:
|
||||
os.remove(self.path)
|
||||
self.changed = True
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.ENOENT:
|
||||
raise CertificateError(exc)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type='str', default='present', choices=['absent', 'present']),
|
||||
force=dict(type='bool', default=False),
|
||||
type=dict(type='str', choices=['host', 'user']),
|
||||
signing_key=dict(type='path'),
|
||||
public_key=dict(type='path'),
|
||||
path=dict(type='path', required=True),
|
||||
identifier=dict(type='str'),
|
||||
serial_number=dict(type='int'),
|
||||
valid_from=dict(type='str'),
|
||||
valid_to=dict(type='str'),
|
||||
valid_at=dict(type='str'),
|
||||
principals=dict(type='list', elements='str'),
|
||||
options=dict(type='list', elements='str'),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
required_if=[('state', 'present', ['type', 'signing_key', 'public_key', 'valid_from', 'valid_to'])],
|
||||
)
|
||||
|
||||
def isBaseDir(path):
|
||||
base_dir = os.path.dirname(path) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
)
|
||||
if module.params['state'] == "present":
|
||||
isBaseDir(module.params['signing_key'])
|
||||
isBaseDir(module.params['public_key'])
|
||||
|
||||
isBaseDir(module.params['path'])
|
||||
|
||||
certificate = Certificate(module)
|
||||
|
||||
if certificate.state == 'present':
|
||||
|
||||
if module.check_mode:
|
||||
certificate.changed = module.params['force'] or not certificate.is_valid(module)
|
||||
else:
|
||||
try:
|
||||
certificate.generate(module)
|
||||
except Exception as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
else:
|
||||
|
||||
if module.check_mode:
|
||||
certificate.changed = os.path.exists(module.params['path'])
|
||||
if certificate.changed:
|
||||
certificate.cert_info = {}
|
||||
else:
|
||||
try:
|
||||
certificate.remove()
|
||||
except Exception as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
result = certificate.dump()
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
490
plugins/modules/openssh_keypair.py
Normal file
490
plugins/modules/openssh_keypair.py
Normal file
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {
|
||||
'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'
|
||||
}
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: openssh_keypair
|
||||
author: "David Kainz (@lolcube)"
|
||||
short_description: Generate OpenSSH private and public keys.
|
||||
description:
|
||||
- "This module allows one to (re)generate OpenSSH private and public keys. It uses
|
||||
ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519)
|
||||
or C(ecdsa) private keys."
|
||||
requirements:
|
||||
- "ssh-keygen"
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the private and public keys should exist or not, taking action if the state is different from what is stated.
|
||||
type: str
|
||||
default: present
|
||||
choices: [ present, absent ]
|
||||
size:
|
||||
description:
|
||||
- "Specifies the number of bits in the private key to create. For RSA keys, the minimum size is 1024 bits and the default is 4096 bits.
|
||||
Generally, 2048 bits is considered sufficient. DSA keys must be exactly 1024 bits as specified by FIPS 186-2.
|
||||
For ECDSA keys, size determines the key length by selecting from one of three elliptic curve sizes: 256, 384 or 521 bits.
|
||||
Attempting to use bit lengths other than these three values for ECDSA keys will cause this module to fail.
|
||||
Ed25519 keys have a fixed length and the size will be ignored."
|
||||
type: int
|
||||
type:
|
||||
description:
|
||||
- "The algorithm used to generate the SSH private key. C(rsa1) is for protocol version 1.
|
||||
C(rsa1) is deprecated and may not be supported by every version of ssh-keygen."
|
||||
type: str
|
||||
default: rsa
|
||||
choices: ['rsa', 'dsa', 'rsa1', 'ecdsa', 'ed25519']
|
||||
force:
|
||||
description:
|
||||
- Should the key be regenerated even if it already exists
|
||||
type: bool
|
||||
default: false
|
||||
path:
|
||||
description:
|
||||
- Name of the files containing the public and private key. The file containing the public key will have the extension C(.pub).
|
||||
type: path
|
||||
required: true
|
||||
comment:
|
||||
description:
|
||||
- Provides a new comment to the public key. When checking if the key is in the correct state this will be ignored.
|
||||
type: str
|
||||
regenerate:
|
||||
description:
|
||||
- Allows to configure in which situations the module is allowed to regenerate private keys.
|
||||
The module will always generate a new key if the destination file does not exist.
|
||||
- By default, the key will be regenerated when it doesn't match the module's options,
|
||||
except when the key cannot be read or the passphrase does not match. Please note that
|
||||
this B(changed) for Ansible 2.10. For Ansible 2.9, the behavior was as if C(full_idempotence)
|
||||
is specified.
|
||||
- If set to C(never), the module will fail if the key cannot be read or the passphrase
|
||||
isn't matching, and will never regenerate an existing key.
|
||||
- If set to C(fail), the module will fail if the key does not correspond to the module's
|
||||
options.
|
||||
- If set to C(partial_idempotence), the key will be regenerated if it does not conform to
|
||||
the module's options. The key is B(not) regenerated if it cannot be read (broken file),
|
||||
the key is protected by an unknown passphrase, or when they key is not protected by a
|
||||
passphrase, but a passphrase is specified.
|
||||
- If set to C(full_idempotence), the key will be regenerated if it does not conform to the
|
||||
module's options. This is also the case if the key cannot be read (broken file), the key
|
||||
is protected by an unknown passphrase, or when they key is not protected by a passphrase,
|
||||
but a passphrase is specified. Make sure you have a B(backup) when using this option!
|
||||
- If set to C(always), the module will always regenerate the key. This is equivalent to
|
||||
setting I(force) to C(yes).
|
||||
- Note that adjusting the comment and the permissions can be changed without regeneration.
|
||||
Therefore, even for C(never), the task can result in changed.
|
||||
type: str
|
||||
choices:
|
||||
- never
|
||||
- fail
|
||||
- partial_idempotence
|
||||
- full_idempotence
|
||||
- always
|
||||
default: partial_idempotence
|
||||
notes:
|
||||
- In case the ssh key is broken or password protected, the module will fail. Set the I(force) option to C(yes) if you want to regenerate the keypair.
|
||||
|
||||
extends_documentation_fragment: files
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
# Generate an OpenSSH keypair with the default values (4096 bits, rsa)
|
||||
- openssh_keypair:
|
||||
path: /tmp/id_ssh_rsa
|
||||
|
||||
# Generate an OpenSSH rsa keypair with a different size (2048 bits)
|
||||
- openssh_keypair:
|
||||
path: /tmp/id_ssh_rsa
|
||||
size: 2048
|
||||
|
||||
# Force regenerate an OpenSSH keypair if it already exists
|
||||
- openssh_keypair:
|
||||
path: /tmp/id_ssh_rsa
|
||||
force: True
|
||||
|
||||
# Generate an OpenSSH keypair with a different algorithm (dsa)
|
||||
- openssh_keypair:
|
||||
path: /tmp/id_ssh_dsa
|
||||
type: dsa
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
size:
|
||||
description: Size (in bits) of the SSH private key
|
||||
returned: changed or success
|
||||
type: int
|
||||
sample: 4096
|
||||
type:
|
||||
description: Algorithm used to generate the SSH private key
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: rsa
|
||||
filename:
|
||||
description: Path to the generated SSH private key file
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /tmp/id_ssh_rsa
|
||||
fingerprint:
|
||||
description: The fingerprint of the key.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: SHA256:r4YCZxihVjedH2OlfjVGI6Y5xAYtdCwk8VxKyzVyYfM
|
||||
public_key:
|
||||
description: The public key of the generated SSH private key
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: ssh-rsa AAAAB3Nza(...omitted...)veL4E3Xcw== test_key
|
||||
comment:
|
||||
description: The comment of the generated key
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: test@comment
|
||||
'''
|
||||
|
||||
import os
|
||||
import stat
|
||||
import errno
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils._text import to_native
|
||||
|
||||
|
||||
class KeypairError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Keypair(object):
|
||||
|
||||
def __init__(self, module):
|
||||
self.path = module.params['path']
|
||||
self.state = module.params['state']
|
||||
self.force = module.params['force']
|
||||
self.size = module.params['size']
|
||||
self.type = module.params['type']
|
||||
self.comment = module.params['comment']
|
||||
self.changed = False
|
||||
self.check_mode = module.check_mode
|
||||
self.privatekey = None
|
||||
self.fingerprint = {}
|
||||
self.public_key = {}
|
||||
self.regenerate = module.params['regenerate']
|
||||
if self.regenerate == 'always':
|
||||
self.force = True
|
||||
|
||||
if self.type in ('rsa', 'rsa1'):
|
||||
self.size = 4096 if self.size is None else self.size
|
||||
if self.size < 1024:
|
||||
module.fail_json(msg=('For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. '
|
||||
'Attempting to use bit lengths under 1024 will cause the module to fail.'))
|
||||
|
||||
if self.type == 'dsa':
|
||||
self.size = 1024 if self.size is None else self.size
|
||||
if self.size != 1024:
|
||||
module.fail_json(msg=('DSA keys must be exactly 1024 bits as specified by FIPS 186-2.'))
|
||||
|
||||
if self.type == 'ecdsa':
|
||||
self.size = 256 if self.size is None else self.size
|
||||
if self.size not in (256, 384, 521):
|
||||
module.fail_json(msg=('For ECDSA keys, size determines the key length by selecting from '
|
||||
'one of three elliptic curve sizes: 256, 384 or 521 bits. '
|
||||
'Attempting to use bit lengths other than these three values for '
|
||||
'ECDSA keys will cause this module to fail. '))
|
||||
if self.type == 'ed25519':
|
||||
self.size = 256
|
||||
|
||||
def generate(self, module):
|
||||
# generate a keypair
|
||||
if self.force or not self.isPrivateKeyValid(module, perms_required=False):
|
||||
args = [
|
||||
module.get_bin_path('ssh-keygen', True),
|
||||
'-q',
|
||||
'-N', '',
|
||||
'-b', str(self.size),
|
||||
'-t', self.type,
|
||||
'-f', self.path,
|
||||
]
|
||||
|
||||
if self.comment:
|
||||
args.extend(['-C', self.comment])
|
||||
else:
|
||||
args.extend(['-C', ""])
|
||||
|
||||
try:
|
||||
if os.path.exists(self.path) and not os.access(self.path, os.W_OK):
|
||||
os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR)
|
||||
self.changed = True
|
||||
stdin_data = None
|
||||
if os.path.exists(self.path):
|
||||
stdin_data = 'y'
|
||||
module.run_command(args, data=stdin_data)
|
||||
proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path])
|
||||
self.fingerprint = proc[1].split()
|
||||
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
|
||||
self.public_key = pubkey[1].strip('\n')
|
||||
except Exception as e:
|
||||
self.remove()
|
||||
module.fail_json(msg="%s" % to_native(e))
|
||||
|
||||
elif not self.isPublicKeyValid(module, perms_required=False):
|
||||
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
|
||||
pubkey = pubkey[1].strip('\n')
|
||||
try:
|
||||
self.changed = True
|
||||
with open(self.path + ".pub", "w") as pubkey_f:
|
||||
pubkey_f.write(pubkey + '\n')
|
||||
os.chmod(self.path + ".pub", stat.S_IWUSR + stat.S_IRUSR + stat.S_IRGRP + stat.S_IROTH)
|
||||
except IOError:
|
||||
module.fail_json(
|
||||
msg='The public key is missing or does not match the private key. '
|
||||
'Unable to regenerate the public key.')
|
||||
self.public_key = pubkey
|
||||
|
||||
if self.comment:
|
||||
try:
|
||||
if os.path.exists(self.path) and not os.access(self.path, os.W_OK):
|
||||
os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR)
|
||||
args = [module.get_bin_path('ssh-keygen', True),
|
||||
'-q', '-o', '-c', '-C', self.comment, '-f', self.path]
|
||||
module.run_command(args)
|
||||
except IOError:
|
||||
module.fail_json(
|
||||
msg='Unable to update the comment for the public key.')
|
||||
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
if module.set_fs_attributes_if_different(file_args, False):
|
||||
self.changed = True
|
||||
file_args['path'] = file_args['path'] + '.pub'
|
||||
if module.set_fs_attributes_if_different(file_args, False):
|
||||
self.changed = True
|
||||
|
||||
def _check_pass_protected_or_broken_key(self, module):
|
||||
key_state = module.run_command([module.get_bin_path('ssh-keygen', True),
|
||||
'-P', '', '-yf', self.path], check_rc=False)
|
||||
if key_state[0] == 255 or 'is not a public key file' in key_state[2]:
|
||||
return True
|
||||
if 'incorrect passphrase' in key_state[2] or 'load failed' in key_state[2]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def isPrivateKeyValid(self, module, perms_required=True):
|
||||
|
||||
# check if the key is correct
|
||||
def _check_state():
|
||||
return os.path.exists(self.path)
|
||||
|
||||
if not _check_state():
|
||||
return False
|
||||
|
||||
if self._check_pass_protected_or_broken_key(module):
|
||||
if self.regenerate in ('full_idempotence', 'always'):
|
||||
return False
|
||||
module.fail_json(msg='Unable to read the key. The key is protected with a passphrase or broken.'
|
||||
' Will not proceed. To force regeneration, call the module with `generate`'
|
||||
' set to `full_idempotence` or `always`, or with `force=yes`.')
|
||||
|
||||
proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path], check_rc=False)
|
||||
if not proc[0] == 0:
|
||||
if os.path.isdir(self.path):
|
||||
module.fail_json(msg='%s is a directory. Please specify a path to a file.' % (self.path))
|
||||
|
||||
if self.regenerate in ('full_idempotence', 'always'):
|
||||
return False
|
||||
module.fail_json(msg='Unable to read the key. The key is protected with a passphrase or broken.'
|
||||
' Will not proceed. To force regeneration, call the module with `generate`'
|
||||
' set to `full_idempotence` or `always`, or with `force=yes`.')
|
||||
|
||||
fingerprint = proc[1].split()
|
||||
keysize = int(fingerprint[0])
|
||||
keytype = fingerprint[-1][1:-1].lower()
|
||||
|
||||
self.fingerprint = fingerprint
|
||||
|
||||
if self.regenerate == 'never':
|
||||
return True
|
||||
|
||||
def _check_type():
|
||||
return self.type == keytype
|
||||
|
||||
def _check_size():
|
||||
return self.size == keysize
|
||||
|
||||
if not (_check_type() and _check_size()):
|
||||
if self.regenerate in ('partial_idempotence', 'full_idempotence', 'always'):
|
||||
return False
|
||||
module.fail_json(msg='Key has wrong type and/or size.'
|
||||
' Will not proceed. To force regeneration, call the module with `generate`'
|
||||
' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=yes`.')
|
||||
|
||||
def _check_perms(module):
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
return not module.set_fs_attributes_if_different(file_args, False)
|
||||
|
||||
return not perms_required or _check_perms(module)
|
||||
|
||||
def isPublicKeyValid(self, module, perms_required=True):
|
||||
|
||||
def _get_pubkey_content():
|
||||
if os.path.exists(self.path + ".pub"):
|
||||
with open(self.path + ".pub", "r") as pubkey_f:
|
||||
present_pubkey = pubkey_f.read().strip(' \n')
|
||||
return present_pubkey
|
||||
else:
|
||||
return False
|
||||
|
||||
def _parse_pubkey(pubkey_content):
|
||||
if pubkey_content:
|
||||
parts = pubkey_content.split(' ', 2)
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
return parts[0], parts[1], '' if len(parts) <= 2 else parts[2]
|
||||
return False
|
||||
|
||||
def _pubkey_valid(pubkey):
|
||||
if pubkey_parts and _parse_pubkey(pubkey):
|
||||
return pubkey_parts[:2] == _parse_pubkey(pubkey)[:2]
|
||||
return False
|
||||
|
||||
def _comment_valid():
|
||||
if pubkey_parts:
|
||||
return pubkey_parts[2] == self.comment
|
||||
return False
|
||||
|
||||
def _check_perms(module):
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
file_args['path'] = file_args['path'] + '.pub'
|
||||
return not module.set_fs_attributes_if_different(file_args, False)
|
||||
|
||||
pubkey_parts = _parse_pubkey(_get_pubkey_content())
|
||||
|
||||
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
|
||||
pubkey = pubkey[1].strip('\n')
|
||||
if _pubkey_valid(pubkey):
|
||||
self.public_key = pubkey
|
||||
else:
|
||||
return False
|
||||
|
||||
if self.comment:
|
||||
if not _comment_valid():
|
||||
return False
|
||||
|
||||
if perms_required:
|
||||
if not _check_perms(module):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def dump(self):
|
||||
# return result as a dict
|
||||
|
||||
"""Serialize the object into a dictionary."""
|
||||
result = {
|
||||
'changed': self.changed,
|
||||
'size': self.size,
|
||||
'type': self.type,
|
||||
'filename': self.path,
|
||||
# On removal this has no value
|
||||
'fingerprint': self.fingerprint[1] if self.fingerprint else '',
|
||||
'public_key': self.public_key,
|
||||
'comment': self.comment if self.comment else '',
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def remove(self):
|
||||
"""Remove the resource from the filesystem."""
|
||||
|
||||
try:
|
||||
os.remove(self.path)
|
||||
self.changed = True
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.ENOENT:
|
||||
raise KeypairError(exc)
|
||||
else:
|
||||
pass
|
||||
|
||||
if os.path.exists(self.path + ".pub"):
|
||||
try:
|
||||
os.remove(self.path + ".pub")
|
||||
self.changed = True
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.ENOENT:
|
||||
raise KeypairError(exc)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
# Define Ansible Module
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type='str', default='present', choices=['present', 'absent']),
|
||||
size=dict(type='int'),
|
||||
type=dict(type='str', default='rsa', choices=['rsa', 'dsa', 'rsa1', 'ecdsa', 'ed25519']),
|
||||
force=dict(type='bool', default=False),
|
||||
path=dict(type='path', required=True),
|
||||
comment=dict(type='str'),
|
||||
regenerate=dict(
|
||||
type='str',
|
||||
default='partial_idempotence',
|
||||
choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always']
|
||||
),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
)
|
||||
|
||||
# Check if Path exists
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
)
|
||||
|
||||
keypair = Keypair(module)
|
||||
|
||||
if keypair.state == 'present':
|
||||
|
||||
if module.check_mode:
|
||||
result = keypair.dump()
|
||||
result['changed'] = keypair.force or not keypair.isPrivateKeyValid(module) or not keypair.isPublicKeyValid(module)
|
||||
module.exit_json(**result)
|
||||
|
||||
try:
|
||||
keypair.generate(module)
|
||||
except Exception as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
else:
|
||||
|
||||
if module.check_mode:
|
||||
keypair.changed = os.path.exists(module.params['path'])
|
||||
if keypair.changed:
|
||||
keypair.fingerprint = {}
|
||||
result = keypair.dump()
|
||||
module.exit_json(**result)
|
||||
|
||||
try:
|
||||
keypair.remove()
|
||||
except Exception as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
result = keypair.dump()
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
2723
plugins/modules/openssl_certificate.py
Normal file
2723
plugins/modules/openssl_certificate.py
Normal file
File diff suppressed because it is too large
Load Diff
861
plugins/modules/openssl_certificate_info.py
Normal file
861
plugins/modules/openssl_certificate_info.py
Normal file
@@ -0,0 +1,861 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_certificate_info
|
||||
short_description: Provide information of OpenSSL X.509 certificates
|
||||
description:
|
||||
- This module allows one to query information on OpenSSL certificates.
|
||||
- It uses the pyOpenSSL or cryptography python library to interact with OpenSSL. If both the
|
||||
cryptography and PyOpenSSL libraries are available (and meet the minimum version requirements)
|
||||
cryptography will be preferred as a backend over PyOpenSSL (unless the backend is forced with
|
||||
C(select_crypto_backend)). Please note that the PyOpenSSL backend was deprecated in Ansible 2.9
|
||||
and will be removed in Ansible 2.13.
|
||||
requirements:
|
||||
- PyOpenSSL >= 0.15 or cryptography >= 1.6
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
- Yanis Guenane (@Spredzy)
|
||||
- Markus Teufelberger (@MarkusTeufelberger)
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- Remote absolute path where the certificate file is loaded from.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: path
|
||||
content:
|
||||
description:
|
||||
- Content of the X.509 certificate in PEM format.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: str
|
||||
valid_at:
|
||||
description:
|
||||
- A dict of names mapping to time specifications. Every time specified here
|
||||
will be checked whether the certificate is valid at this point. See the
|
||||
C(valid_at) return value for informations on the result.
|
||||
- Time can be specified either as relative time or as absolute timestamp.
|
||||
- Time will always be interpreted as UTC.
|
||||
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
|
||||
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h), and ASN.1 TIME (i.e. pattern C(YYYYMMDDHHMMSSZ)).
|
||||
Note that all timestamps will be treated as being in UTC.
|
||||
type: dict
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
- Please note that the C(pyopenssl) backend has been deprecated in Ansible 2.9, and will be removed in Ansible 2.13.
|
||||
From that point on, only the C(cryptography) backend will be available.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
|
||||
notes:
|
||||
- All timestamp values are provided in ASN.1 TIME format, i.e. following the C(YYYYMMDDHHMMSSZ) pattern.
|
||||
They are all in UTC.
|
||||
seealso:
|
||||
- module: openssl_certificate
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate a Self Signed OpenSSL certificate
|
||||
openssl_certificate:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
csr_path: /etc/ssl/csr/ansible.com.csr
|
||||
provider: selfsigned
|
||||
|
||||
|
||||
# Get information on the certificate
|
||||
|
||||
- name: Get information on generated certificate
|
||||
openssl_certificate_info:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
register: result
|
||||
|
||||
- name: Dump information
|
||||
debug:
|
||||
var: result
|
||||
|
||||
|
||||
# Check whether the certificate is valid or not valid at certain times, fail
|
||||
# if this is not the case. The first task (openssl_certificate_info) collects
|
||||
# the information, and the second task (assert) validates the result and
|
||||
# makes the playbook fail in case something is not as expected.
|
||||
|
||||
- name: Test whether that certificate is valid tomorrow and/or in three weeks
|
||||
openssl_certificate_info:
|
||||
path: /etc/ssl/crt/ansible.com.crt
|
||||
valid_at:
|
||||
point_1: "+1d"
|
||||
point_2: "+3w"
|
||||
register: result
|
||||
|
||||
- name: Validate that certificate is valid tomorrow, but not in three weeks
|
||||
assert:
|
||||
that:
|
||||
- result.valid_at.point_1 # valid in one day
|
||||
- not result.valid_at.point_2 # not valid in three weeks
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
expired:
|
||||
description: Whether the certificate is expired (i.e. C(notAfter) is in the past)
|
||||
returned: success
|
||||
type: bool
|
||||
basic_constraints:
|
||||
description: Entries in the C(basic_constraints) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[CA:TRUE, pathlen:1]"
|
||||
basic_constraints_critical:
|
||||
description: Whether the C(basic_constraints) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
extended_key_usage:
|
||||
description: Entries in the C(extended_key_usage) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[Biometric Info, DVCS, Time Stamping]"
|
||||
extended_key_usage_critical:
|
||||
description: Whether the C(extended_key_usage) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
extensions_by_oid:
|
||||
description: Returns a dictionary for every extension OID
|
||||
returned: success
|
||||
type: dict
|
||||
contains:
|
||||
critical:
|
||||
description: Whether the extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
value:
|
||||
description: The Base64 encoded value (in DER format) of the extension
|
||||
returned: success
|
||||
type: str
|
||||
sample: "MAMCAQU="
|
||||
sample: '{"1.3.6.1.5.5.7.1.24": { "critical": false, "value": "MAMCAQU="}}'
|
||||
key_usage:
|
||||
description: Entries in the C(key_usage) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: str
|
||||
sample: "[Key Agreement, Data Encipherment]"
|
||||
key_usage_critical:
|
||||
description: Whether the C(key_usage) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
subject_alt_name:
|
||||
description: Entries in the C(subject_alt_name) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[DNS:www.ansible.com, IP:1.2.3.4]"
|
||||
subject_alt_name_critical:
|
||||
description: Whether the C(subject_alt_name) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
ocsp_must_staple:
|
||||
description: C(yes) if the OCSP Must Staple extension is present, C(none) otherwise.
|
||||
returned: success
|
||||
type: bool
|
||||
ocsp_must_staple_critical:
|
||||
description: Whether the C(ocsp_must_staple) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
issuer:
|
||||
description:
|
||||
- The certificate's issuer.
|
||||
- Note that for repeated values, only the last one will be returned.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: '{"organizationName": "Ansible", "commonName": "ca.example.com"}'
|
||||
issuer_ordered:
|
||||
description: The certificate's issuer as an ordered list of tuples.
|
||||
returned: success
|
||||
type: list
|
||||
elements: list
|
||||
sample: '[["organizationName", "Ansible"], ["commonName": "ca.example.com"]]'
|
||||
version_added: "2.9"
|
||||
subject:
|
||||
description:
|
||||
- The certificate's subject as a dictionary.
|
||||
- Note that for repeated values, only the last one will be returned.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: '{"commonName": "www.example.com", "emailAddress": "test@example.com"}'
|
||||
subject_ordered:
|
||||
description: The certificate's subject as an ordered list of tuples.
|
||||
returned: success
|
||||
type: list
|
||||
elements: list
|
||||
sample: '[["commonName", "www.example.com"], ["emailAddress": "test@example.com"]]'
|
||||
version_added: "2.9"
|
||||
not_after:
|
||||
description: C(notAfter) date as ASN.1 TIME
|
||||
returned: success
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
not_before:
|
||||
description: C(notBefore) date as ASN.1 TIME
|
||||
returned: success
|
||||
type: str
|
||||
sample: 20190331202428Z
|
||||
public_key:
|
||||
description: Certificate's public key in PEM format
|
||||
returned: success
|
||||
type: str
|
||||
sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..."
|
||||
public_key_fingerprints:
|
||||
description:
|
||||
- Fingerprints of certificate's public key.
|
||||
- For every hash algorithm available, the fingerprint is computed.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63',
|
||||
'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..."
|
||||
signature_algorithm:
|
||||
description: The signature algorithm used to sign the certificate.
|
||||
returned: success
|
||||
type: str
|
||||
sample: sha256WithRSAEncryption
|
||||
serial_number:
|
||||
description: The certificate's serial number.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 1234
|
||||
version:
|
||||
description: The certificate version.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 3
|
||||
valid_at:
|
||||
description: For every time stamp provided in the I(valid_at) option, a
|
||||
boolean whether the certificate is valid at that point in time
|
||||
or not.
|
||||
returned: success
|
||||
type: dict
|
||||
subject_key_identifier:
|
||||
description:
|
||||
- The certificate's subject key identifier.
|
||||
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
|
||||
- Is C(none) if the C(SubjectKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: str
|
||||
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
|
||||
version_added: "2.9"
|
||||
authority_key_identifier:
|
||||
description:
|
||||
- The certificate's authority key identifier.
|
||||
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
|
||||
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: str
|
||||
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
|
||||
version_added: "2.9"
|
||||
authority_cert_issuer:
|
||||
description:
|
||||
- The certificate's authority cert issuer as a list of general names.
|
||||
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[DNS:www.ansible.com, IP:1.2.3.4]"
|
||||
version_added: "2.9"
|
||||
authority_cert_serial_number:
|
||||
description:
|
||||
- The certificate's authority cert serial number.
|
||||
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: int
|
||||
sample: '12345'
|
||||
version_added: "2.9"
|
||||
ocsp_uri:
|
||||
description: The OCSP responder URI, if included in the certificate. Will be
|
||||
C(none) if no OCSP responder URI is included.
|
||||
returned: success
|
||||
type: str
|
||||
version_added: "2.9"
|
||||
'''
|
||||
|
||||
|
||||
import abc
|
||||
import binascii
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils.six import string_types
|
||||
from ansible.module_utils._text import to_native, to_text, to_bytes
|
||||
from ansible_collections.ansible.netcommon.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# OpenSSL 1.1.0 or newer
|
||||
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
|
||||
else:
|
||||
# OpenSSL 1.0.x or older
|
||||
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
class CertificateInfo(crypto_utils.OpenSSLObject):
|
||||
def __init__(self, module, backend):
|
||||
super(CertificateInfo, self).__init__(
|
||||
module.params['path'] or '',
|
||||
'present',
|
||||
False,
|
||||
module.check_mode,
|
||||
)
|
||||
self.backend = backend
|
||||
self.module = module
|
||||
self.content = module.params['content']
|
||||
if self.content is not None:
|
||||
self.content = self.content.encode('utf-8')
|
||||
|
||||
self.valid_at = module.params['valid_at']
|
||||
if self.valid_at:
|
||||
for k, v in self.valid_at.items():
|
||||
if not isinstance(v, string_types):
|
||||
self.module.fail_json(
|
||||
msg='The value for valid_at.{0} must be of type string (got {1})'.format(k, type(v))
|
||||
)
|
||||
self.valid_at[k] = crypto_utils.get_relative_time_option(v, 'valid_at.{0}'.format(k))
|
||||
|
||||
def generate(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
def dump(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_signature_algorithm(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_ordered(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_issuer_ordered(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_version(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_extended_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_basic_constraints(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_ocsp_must_staple(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_alt_name(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_not_before(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_not_after(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key(self, binary):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_authority_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_serial_number(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_all_extensions(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_ocsp_uri(self):
|
||||
pass
|
||||
|
||||
def get_info(self):
|
||||
result = dict()
|
||||
self.cert = crypto_utils.load_certificate(self.path, content=self.content, backend=self.backend)
|
||||
|
||||
result['signature_algorithm'] = self._get_signature_algorithm()
|
||||
subject = self._get_subject_ordered()
|
||||
issuer = self._get_issuer_ordered()
|
||||
result['subject'] = dict()
|
||||
for k, v in subject:
|
||||
result['subject'][k] = v
|
||||
result['subject_ordered'] = subject
|
||||
result['issuer'] = dict()
|
||||
for k, v in issuer:
|
||||
result['issuer'][k] = v
|
||||
result['issuer_ordered'] = issuer
|
||||
result['version'] = self._get_version()
|
||||
result['key_usage'], result['key_usage_critical'] = self._get_key_usage()
|
||||
result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage()
|
||||
result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints()
|
||||
result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple()
|
||||
result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name()
|
||||
|
||||
not_before = self._get_not_before()
|
||||
not_after = self._get_not_after()
|
||||
result['not_before'] = not_before.strftime(TIMESTAMP_FORMAT)
|
||||
result['not_after'] = not_after.strftime(TIMESTAMP_FORMAT)
|
||||
result['expired'] = not_after < datetime.datetime.utcnow()
|
||||
|
||||
result['valid_at'] = dict()
|
||||
if self.valid_at:
|
||||
for k, v in self.valid_at.items():
|
||||
result['valid_at'][k] = not_before <= v <= not_after
|
||||
|
||||
result['public_key'] = self._get_public_key(binary=False)
|
||||
pk = self._get_public_key(binary=True)
|
||||
result['public_key_fingerprints'] = crypto_utils.get_fingerprint_of_bytes(pk) if pk is not None else dict()
|
||||
|
||||
if self.backend != 'pyopenssl':
|
||||
ski = self._get_subject_key_identifier()
|
||||
if ski is not None:
|
||||
ski = to_native(binascii.hexlify(ski))
|
||||
ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)])
|
||||
result['subject_key_identifier'] = ski
|
||||
|
||||
aki, aci, acsn = self._get_authority_key_identifier()
|
||||
if aki is not None:
|
||||
aki = to_native(binascii.hexlify(aki))
|
||||
aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)])
|
||||
result['authority_key_identifier'] = aki
|
||||
result['authority_cert_issuer'] = aci
|
||||
result['authority_cert_serial_number'] = acsn
|
||||
|
||||
result['serial_number'] = self._get_serial_number()
|
||||
result['extensions_by_oid'] = self._get_all_extensions()
|
||||
result['ocsp_uri'] = self._get_ocsp_uri()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class CertificateInfoCryptography(CertificateInfo):
|
||||
"""Validate the supplied cert, using the cryptography backend"""
|
||||
def __init__(self, module):
|
||||
super(CertificateInfoCryptography, self).__init__(module, 'cryptography')
|
||||
|
||||
def _get_signature_algorithm(self):
|
||||
return crypto_utils.cryptography_oid_to_name(self.cert.signature_algorithm_oid)
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
result = []
|
||||
for attribute in self.cert.subject:
|
||||
result.append([crypto_utils.cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
return result
|
||||
|
||||
def _get_issuer_ordered(self):
|
||||
result = []
|
||||
for attribute in self.cert.issuer:
|
||||
result.append([crypto_utils.cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
return result
|
||||
|
||||
def _get_version(self):
|
||||
if self.cert.version == x509.Version.v1:
|
||||
return 1
|
||||
if self.cert.version == x509.Version.v3:
|
||||
return 3
|
||||
return "unknown"
|
||||
|
||||
def _get_key_usage(self):
|
||||
try:
|
||||
current_key_ext = self.cert.extensions.get_extension_for_class(x509.KeyUsage)
|
||||
current_key_usage = current_key_ext.value
|
||||
key_usage = dict(
|
||||
digital_signature=current_key_usage.digital_signature,
|
||||
content_commitment=current_key_usage.content_commitment,
|
||||
key_encipherment=current_key_usage.key_encipherment,
|
||||
data_encipherment=current_key_usage.data_encipherment,
|
||||
key_agreement=current_key_usage.key_agreement,
|
||||
key_cert_sign=current_key_usage.key_cert_sign,
|
||||
crl_sign=current_key_usage.crl_sign,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
)
|
||||
if key_usage['key_agreement']:
|
||||
key_usage.update(dict(
|
||||
encipher_only=current_key_usage.encipher_only,
|
||||
decipher_only=current_key_usage.decipher_only
|
||||
))
|
||||
|
||||
key_usage_names = dict(
|
||||
digital_signature='Digital Signature',
|
||||
content_commitment='Non Repudiation',
|
||||
key_encipherment='Key Encipherment',
|
||||
data_encipherment='Data Encipherment',
|
||||
key_agreement='Key Agreement',
|
||||
key_cert_sign='Certificate Sign',
|
||||
crl_sign='CRL Sign',
|
||||
encipher_only='Encipher Only',
|
||||
decipher_only='Decipher Only',
|
||||
)
|
||||
return sorted([
|
||||
key_usage_names[name] for name, value in key_usage.items() if value
|
||||
]), current_key_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
|
||||
return sorted([
|
||||
crypto_utils.cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value
|
||||
]), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.cert.extensions.get_extension_for_class(x509.BasicConstraints)
|
||||
result = []
|
||||
result.append('CA:{0}'.format('TRUE' if ext_keyusage_ext.value.ca else 'FALSE'))
|
||||
if ext_keyusage_ext.value.path_length is not None:
|
||||
result.append('pathlen:{0}'.format(ext_keyusage_ext.value.path_length))
|
||||
return sorted(result), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
try:
|
||||
try:
|
||||
# This only works with cryptography >= 2.1
|
||||
tlsfeature_ext = self.cert.extensions.get_extension_for_class(x509.TLSFeature)
|
||||
value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
|
||||
except AttributeError as dummy:
|
||||
# Fallback for cryptography < 2.1
|
||||
oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24")
|
||||
tlsfeature_ext = self.cert.extensions.get_extension_for_oid(oid)
|
||||
value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05"
|
||||
return value, tlsfeature_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
try:
|
||||
san_ext = self.cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
result = [crypto_utils.cryptography_decode_name(san) for san in san_ext.value]
|
||||
return result, san_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_not_before(self):
|
||||
return self.cert.not_valid_before
|
||||
|
||||
def _get_not_after(self):
|
||||
return self.cert.not_valid_after
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
return self.cert.public_key().public_bytes(
|
||||
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
try:
|
||||
ext = self.cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
||||
return ext.value.digest
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
try:
|
||||
ext = self.cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
|
||||
issuer = None
|
||||
if ext.value.authority_cert_issuer is not None:
|
||||
issuer = [crypto_utils.cryptography_decode_name(san) for san in ext.value.authority_cert_issuer]
|
||||
return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, None, None
|
||||
|
||||
def _get_serial_number(self):
|
||||
return self.cert.serial_number
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return crypto_utils.cryptography_get_extensions_from_cert(self.cert)
|
||||
|
||||
def _get_ocsp_uri(self):
|
||||
try:
|
||||
ext = self.cert.extensions.get_extension_for_class(x509.AuthorityInformationAccess)
|
||||
for desc in ext.value:
|
||||
if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP:
|
||||
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
|
||||
return desc.access_location.value
|
||||
except x509.ExtensionNotFound as dummy:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class CertificateInfoPyOpenSSL(CertificateInfo):
|
||||
"""validate the supplied certificate."""
|
||||
|
||||
def __init__(self, module):
|
||||
super(CertificateInfoPyOpenSSL, self).__init__(module, 'pyopenssl')
|
||||
|
||||
def _get_signature_algorithm(self):
|
||||
return to_text(self.cert.get_signature_algorithm())
|
||||
|
||||
def __get_name(self, name):
|
||||
result = []
|
||||
for sub in name.get_components():
|
||||
result.append([crypto_utils.pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
|
||||
return result
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
return self.__get_name(self.cert.get_subject())
|
||||
|
||||
def _get_issuer_ordered(self):
|
||||
return self.__get_name(self.cert.get_issuer())
|
||||
|
||||
def _get_version(self):
|
||||
# Version numbers in certs are off by one:
|
||||
# v1: 0, v2: 1, v3: 2 ...
|
||||
return self.cert.get_version() + 1
|
||||
|
||||
def _get_extension(self, short_name):
|
||||
for extension_idx in range(0, self.cert.get_extension_count()):
|
||||
extension = self.cert.get_extension(extension_idx)
|
||||
if extension.get_short_name() == short_name:
|
||||
result = [
|
||||
crypto_utils.pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
|
||||
]
|
||||
return sorted(result), bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_key_usage(self):
|
||||
return self._get_extension(b'keyUsage')
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
return self._get_extension(b'extendedKeyUsage')
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
return self._get_extension(b'basicConstraints')
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
extensions = [self.cert.get_extension(i) for i in range(0, self.cert.get_extension_count())]
|
||||
oms_ext = [
|
||||
ext for ext in extensions
|
||||
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
|
||||
]
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
|
||||
# Older versions of libssl don't know about OCSP Must Staple
|
||||
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
|
||||
if oms_ext:
|
||||
return True, bool(oms_ext[0].get_critical())
|
||||
else:
|
||||
return None, False
|
||||
|
||||
def _normalize_san(self, san):
|
||||
if san.startswith('IP Address:'):
|
||||
san = 'IP:' + san[len('IP Address:'):]
|
||||
if san.startswith('IP:'):
|
||||
ip = compat_ipaddress.ip_address(san[3:])
|
||||
san = 'IP:{0}'.format(ip.compressed)
|
||||
return san
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
for extension_idx in range(0, self.cert.get_extension_count()):
|
||||
extension = self.cert.get_extension(extension_idx)
|
||||
if extension.get_short_name() == b'subjectAltName':
|
||||
result = [self._normalize_san(altname.strip()) for altname in
|
||||
to_text(extension, errors='surrogate_or_strict').split(', ')]
|
||||
return result, bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_not_before(self):
|
||||
time_string = to_native(self.cert.get_notBefore())
|
||||
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
|
||||
|
||||
def _get_not_after(self):
|
||||
time_string = to_native(self.cert.get_notAfter())
|
||||
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
|
||||
self.cert.get_pubkey()
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
# pyOpenSSL < 16.0:
|
||||
bio = crypto._new_mem_buf()
|
||||
if binary:
|
||||
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.cert.get_pubkey()._pkey)
|
||||
else:
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.cert.get_pubkey()._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None, None, None
|
||||
|
||||
def _get_serial_number(self):
|
||||
return self.cert.get_serial_number()
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return crypto_utils.pyopenssl_get_extensions_from_cert(self.cert)
|
||||
|
||||
def _get_ocsp_uri(self):
|
||||
for i in range(self.cert.get_extension_count()):
|
||||
ext = self.cert.get_extension(i)
|
||||
if ext.get_short_name() == b'authorityInfoAccess':
|
||||
v = str(ext)
|
||||
m = re.search('^OCSP - URI:(.*)$', v, flags=re.MULTILINE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str'),
|
||||
valid_at=dict(type='dict'),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
|
||||
),
|
||||
required_one_of=(
|
||||
['path', 'content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['path', 'content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
try:
|
||||
if module.params['path'] is not None:
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
)
|
||||
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
# Detect what backend we can use
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# If cryptography is available we'll use it
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Fail if no backend has been found
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
try:
|
||||
getattr(crypto.X509Req, 'get_extensions')
|
||||
except AttributeError:
|
||||
module.fail_json(msg='You need to have PyOpenSSL>=0.15')
|
||||
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', version='2.13')
|
||||
certificate = CertificateInfoPyOpenSSL(module)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
certificate = CertificateInfoCryptography(module)
|
||||
|
||||
result = certificate.get_info()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1143
plugins/modules/openssl_csr.py
Normal file
1143
plugins/modules/openssl_csr.py
Normal file
File diff suppressed because it is too large
Load Diff
665
plugins/modules/openssl_csr_info.py
Normal file
665
plugins/modules/openssl_csr_info.py
Normal file
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_csr_info
|
||||
short_description: Provide information of OpenSSL Certificate Signing Requests (CSR)
|
||||
description:
|
||||
- This module allows one to query information on OpenSSL Certificate Signing Requests (CSR).
|
||||
- In case the CSR signature cannot be validated, the module will fail. In this case, all return
|
||||
variables are still returned.
|
||||
- It uses the pyOpenSSL or cryptography python library to interact with OpenSSL. If both the
|
||||
cryptography and PyOpenSSL libraries are available (and meet the minimum version requirements)
|
||||
cryptography will be preferred as a backend over PyOpenSSL (unless the backend is forced with
|
||||
C(select_crypto_backend)). Please note that the PyOpenSSL backend was deprecated in Ansible 2.9
|
||||
and will be removed in Ansible 2.13.
|
||||
requirements:
|
||||
- PyOpenSSL >= 0.15 or cryptography >= 1.3
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
- Yanis Guenane (@Spredzy)
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- Remote absolute path where the CSR file is loaded from.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: path
|
||||
content:
|
||||
description:
|
||||
- Content of the CSR file.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: str
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
- Please note that the C(pyopenssl) backend has been deprecated in Ansible 2.9, and will be removed in Ansible 2.13.
|
||||
From that point on, only the C(cryptography) backend will be available.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
|
||||
seealso:
|
||||
- module: openssl_csr
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate an OpenSSL Certificate Signing Request
|
||||
openssl_csr:
|
||||
path: /etc/ssl/csr/www.ansible.com.csr
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
common_name: www.ansible.com
|
||||
|
||||
- name: Get information on the CSR
|
||||
openssl_csr_info:
|
||||
path: /etc/ssl/csr/www.ansible.com.csr
|
||||
register: result
|
||||
|
||||
- name: Dump information
|
||||
debug:
|
||||
var: result
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
signature_valid:
|
||||
description:
|
||||
- Whether the CSR's signature is valid.
|
||||
- In case the check returns C(no), the module will fail.
|
||||
returned: success
|
||||
type: bool
|
||||
basic_constraints:
|
||||
description: Entries in the C(basic_constraints) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[CA:TRUE, pathlen:1]"
|
||||
basic_constraints_critical:
|
||||
description: Whether the C(basic_constraints) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
extended_key_usage:
|
||||
description: Entries in the C(extended_key_usage) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[Biometric Info, DVCS, Time Stamping]"
|
||||
extended_key_usage_critical:
|
||||
description: Whether the C(extended_key_usage) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
extensions_by_oid:
|
||||
description: Returns a dictionary for every extension OID
|
||||
returned: success
|
||||
type: dict
|
||||
contains:
|
||||
critical:
|
||||
description: Whether the extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
value:
|
||||
description: The Base64 encoded value (in DER format) of the extension
|
||||
returned: success
|
||||
type: str
|
||||
sample: "MAMCAQU="
|
||||
sample: '{"1.3.6.1.5.5.7.1.24": { "critical": false, "value": "MAMCAQU="}}'
|
||||
key_usage:
|
||||
description: Entries in the C(key_usage) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: str
|
||||
sample: "[Key Agreement, Data Encipherment]"
|
||||
key_usage_critical:
|
||||
description: Whether the C(key_usage) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
subject_alt_name:
|
||||
description: Entries in the C(subject_alt_name) extension, or C(none) if extension is not present.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[DNS:www.ansible.com, IP:1.2.3.4]"
|
||||
subject_alt_name_critical:
|
||||
description: Whether the C(subject_alt_name) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
ocsp_must_staple:
|
||||
description: C(yes) if the OCSP Must Staple extension is present, C(none) otherwise.
|
||||
returned: success
|
||||
type: bool
|
||||
ocsp_must_staple_critical:
|
||||
description: Whether the C(ocsp_must_staple) extension is critical.
|
||||
returned: success
|
||||
type: bool
|
||||
subject:
|
||||
description:
|
||||
- The CSR's subject as a dictionary.
|
||||
- Note that for repeated values, only the last one will be returned.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: '{"commonName": "www.example.com", "emailAddress": "test@example.com"}'
|
||||
subject_ordered:
|
||||
description: The CSR's subject as an ordered list of tuples.
|
||||
returned: success
|
||||
type: list
|
||||
elements: list
|
||||
sample: '[["commonName", "www.example.com"], ["emailAddress": "test@example.com"]]'
|
||||
version_added: "2.9"
|
||||
public_key:
|
||||
description: CSR's public key in PEM format
|
||||
returned: success
|
||||
type: str
|
||||
sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..."
|
||||
public_key_fingerprints:
|
||||
description:
|
||||
- Fingerprints of CSR's public key.
|
||||
- For every hash algorithm available, the fingerprint is computed.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63',
|
||||
'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..."
|
||||
subject_key_identifier:
|
||||
description:
|
||||
- The CSR's subject key identifier.
|
||||
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
|
||||
- Is C(none) if the C(SubjectKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: str
|
||||
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
|
||||
version_added: "2.9"
|
||||
authority_key_identifier:
|
||||
description:
|
||||
- The CSR's authority key identifier.
|
||||
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
|
||||
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: str
|
||||
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
|
||||
version_added: "2.9"
|
||||
authority_cert_issuer:
|
||||
description:
|
||||
- The CSR's authority cert issuer as a list of general names.
|
||||
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: list
|
||||
elements: str
|
||||
sample: "[DNS:www.ansible.com, IP:1.2.3.4]"
|
||||
version_added: "2.9"
|
||||
authority_cert_serial_number:
|
||||
description:
|
||||
- The CSR's authority cert serial number.
|
||||
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
|
||||
returned: success and if the pyOpenSSL backend is I(not) used
|
||||
type: int
|
||||
sample: '12345'
|
||||
version_added: "2.9"
|
||||
'''
|
||||
|
||||
|
||||
import abc
|
||||
import binascii
|
||||
import os
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_native, to_text, to_bytes
|
||||
from ansible_collections.ansible.netcommon.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.3'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# OpenSSL 1.1.0 or newer
|
||||
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
|
||||
else:
|
||||
# OpenSSL 1.0.x or older
|
||||
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
class CertificateSigningRequestInfo(crypto_utils.OpenSSLObject):
|
||||
def __init__(self, module, backend):
|
||||
super(CertificateSigningRequestInfo, self).__init__(
|
||||
module.params['path'] or '',
|
||||
'present',
|
||||
False,
|
||||
module.check_mode,
|
||||
)
|
||||
self.backend = backend
|
||||
self.module = module
|
||||
self.content = module.params['content']
|
||||
if self.content is not None:
|
||||
self.content = self.content.encode('utf-8')
|
||||
|
||||
def generate(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
def dump(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_ordered(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_extended_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_basic_constraints(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_ocsp_must_staple(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_alt_name(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key(self, binary):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_authority_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_all_extensions(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_signature_valid(self):
|
||||
pass
|
||||
|
||||
def get_info(self):
|
||||
result = dict()
|
||||
self.csr = crypto_utils.load_certificate_request(self.path, content=self.content, backend=self.backend)
|
||||
|
||||
subject = self._get_subject_ordered()
|
||||
result['subject'] = dict()
|
||||
for k, v in subject:
|
||||
result['subject'][k] = v
|
||||
result['subject_ordered'] = subject
|
||||
result['key_usage'], result['key_usage_critical'] = self._get_key_usage()
|
||||
result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage()
|
||||
result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints()
|
||||
result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple()
|
||||
result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name()
|
||||
|
||||
result['public_key'] = self._get_public_key(binary=False)
|
||||
pk = self._get_public_key(binary=True)
|
||||
result['public_key_fingerprints'] = crypto_utils.get_fingerprint_of_bytes(pk) if pk is not None else dict()
|
||||
|
||||
if self.backend != 'pyopenssl':
|
||||
ski = self._get_subject_key_identifier()
|
||||
if ski is not None:
|
||||
ski = to_native(binascii.hexlify(ski))
|
||||
ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)])
|
||||
result['subject_key_identifier'] = ski
|
||||
|
||||
aki, aci, acsn = self._get_authority_key_identifier()
|
||||
if aki is not None:
|
||||
aki = to_native(binascii.hexlify(aki))
|
||||
aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)])
|
||||
result['authority_key_identifier'] = aki
|
||||
result['authority_cert_issuer'] = aci
|
||||
result['authority_cert_serial_number'] = acsn
|
||||
|
||||
result['extensions_by_oid'] = self._get_all_extensions()
|
||||
|
||||
result['signature_valid'] = self._is_signature_valid()
|
||||
if not result['signature_valid']:
|
||||
self.module.fail_json(
|
||||
msg='CSR signature is invalid!',
|
||||
**result
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class CertificateSigningRequestInfoCryptography(CertificateSigningRequestInfo):
|
||||
"""Validate the supplied CSR, using the cryptography backend"""
|
||||
def __init__(self, module):
|
||||
super(CertificateSigningRequestInfoCryptography, self).__init__(module, 'cryptography')
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
result = []
|
||||
for attribute in self.csr.subject:
|
||||
result.append([crypto_utils.cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
return result
|
||||
|
||||
def _get_key_usage(self):
|
||||
try:
|
||||
current_key_ext = self.csr.extensions.get_extension_for_class(x509.KeyUsage)
|
||||
current_key_usage = current_key_ext.value
|
||||
key_usage = dict(
|
||||
digital_signature=current_key_usage.digital_signature,
|
||||
content_commitment=current_key_usage.content_commitment,
|
||||
key_encipherment=current_key_usage.key_encipherment,
|
||||
data_encipherment=current_key_usage.data_encipherment,
|
||||
key_agreement=current_key_usage.key_agreement,
|
||||
key_cert_sign=current_key_usage.key_cert_sign,
|
||||
crl_sign=current_key_usage.crl_sign,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
)
|
||||
if key_usage['key_agreement']:
|
||||
key_usage.update(dict(
|
||||
encipher_only=current_key_usage.encipher_only,
|
||||
decipher_only=current_key_usage.decipher_only
|
||||
))
|
||||
|
||||
key_usage_names = dict(
|
||||
digital_signature='Digital Signature',
|
||||
content_commitment='Non Repudiation',
|
||||
key_encipherment='Key Encipherment',
|
||||
data_encipherment='Data Encipherment',
|
||||
key_agreement='Key Agreement',
|
||||
key_cert_sign='Certificate Sign',
|
||||
crl_sign='CRL Sign',
|
||||
encipher_only='Encipher Only',
|
||||
decipher_only='Decipher Only',
|
||||
)
|
||||
return sorted([
|
||||
key_usage_names[name] for name, value in key_usage.items() if value
|
||||
]), current_key_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
|
||||
return sorted([
|
||||
crypto_utils.cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value
|
||||
]), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(x509.BasicConstraints)
|
||||
result = []
|
||||
result.append('CA:{0}'.format('TRUE' if ext_keyusage_ext.value.ca else 'FALSE'))
|
||||
if ext_keyusage_ext.value.path_length is not None:
|
||||
result.append('pathlen:{0}'.format(ext_keyusage_ext.value.path_length))
|
||||
return sorted(result), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
try:
|
||||
try:
|
||||
# This only works with cryptography >= 2.1
|
||||
tlsfeature_ext = self.csr.extensions.get_extension_for_class(x509.TLSFeature)
|
||||
value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
|
||||
except AttributeError as dummy:
|
||||
# Fallback for cryptography < 2.1
|
||||
oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24")
|
||||
tlsfeature_ext = self.csr.extensions.get_extension_for_oid(oid)
|
||||
value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05"
|
||||
return value, tlsfeature_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
try:
|
||||
san_ext = self.csr.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
result = [crypto_utils.cryptography_decode_name(san) for san in san_ext.value]
|
||||
return result, san_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
return self.csr.public_key().public_bytes(
|
||||
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
try:
|
||||
ext = self.csr.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
||||
return ext.value.digest
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
try:
|
||||
ext = self.csr.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
|
||||
issuer = None
|
||||
if ext.value.authority_cert_issuer is not None:
|
||||
issuer = [crypto_utils.cryptography_decode_name(san) for san in ext.value.authority_cert_issuer]
|
||||
return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, None, None
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return crypto_utils.cryptography_get_extensions_from_csr(self.csr)
|
||||
|
||||
def _is_signature_valid(self):
|
||||
return self.csr.is_signature_valid
|
||||
|
||||
|
||||
class CertificateSigningRequestInfoPyOpenSSL(CertificateSigningRequestInfo):
|
||||
"""validate the supplied CSR."""
|
||||
|
||||
def __init__(self, module):
|
||||
super(CertificateSigningRequestInfoPyOpenSSL, self).__init__(module, 'pyopenssl')
|
||||
|
||||
def __get_name(self, name):
|
||||
result = []
|
||||
for sub in name.get_components():
|
||||
result.append([crypto_utils.pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
|
||||
return result
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
return self.__get_name(self.csr.get_subject())
|
||||
|
||||
def _get_extension(self, short_name):
|
||||
for extension in self.csr.get_extensions():
|
||||
if extension.get_short_name() == short_name:
|
||||
result = [
|
||||
crypto_utils.pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
|
||||
]
|
||||
return sorted(result), bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_key_usage(self):
|
||||
return self._get_extension(b'keyUsage')
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
return self._get_extension(b'extendedKeyUsage')
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
return self._get_extension(b'basicConstraints')
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
extensions = self.csr.get_extensions()
|
||||
oms_ext = [
|
||||
ext for ext in extensions
|
||||
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
|
||||
]
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
|
||||
# Older versions of libssl don't know about OCSP Must Staple
|
||||
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
|
||||
if oms_ext:
|
||||
return True, bool(oms_ext[0].get_critical())
|
||||
else:
|
||||
return None, False
|
||||
|
||||
def _normalize_san(self, san):
|
||||
# apparently openssl returns 'IP address' not 'IP' as specifier when converting the subjectAltName to string
|
||||
# although it won't accept this specifier when generating the CSR. (https://github.com/openssl/openssl/issues/4004)
|
||||
if san.startswith('IP Address:'):
|
||||
san = 'IP:' + san[len('IP Address:'):]
|
||||
if san.startswith('IP:'):
|
||||
ip = compat_ipaddress.ip_address(san[3:])
|
||||
san = 'IP:{0}'.format(ip.compressed)
|
||||
return san
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
for extension in self.csr.get_extensions():
|
||||
if extension.get_short_name() == b'subjectAltName':
|
||||
result = [self._normalize_san(altname.strip()) for altname in
|
||||
to_text(extension, errors='surrogate_or_strict').split(', ')]
|
||||
return result, bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
|
||||
self.csr.get_pubkey()
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
bio = crypto._new_mem_buf()
|
||||
if binary:
|
||||
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.csr.get_pubkey()._pkey)
|
||||
else:
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.csr.get_pubkey()._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None, None, None
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return crypto_utils.pyopenssl_get_extensions_from_csr(self.csr)
|
||||
|
||||
def _is_signature_valid(self):
|
||||
try:
|
||||
return bool(self.csr.verify(self.csr.get_pubkey()))
|
||||
except crypto.Error:
|
||||
# OpenSSL error means that key is not consistent
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str'),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
|
||||
),
|
||||
required_one_of=(
|
||||
['path', 'content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['path', 'content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
try:
|
||||
if module.params['path'] is not None:
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
)
|
||||
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
# Detect what backend we can use
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# If cryptography is available we'll use it
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Fail if no backend has been found
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
try:
|
||||
getattr(crypto.X509Req, 'get_extensions')
|
||||
except AttributeError:
|
||||
module.fail_json(msg='You need to have PyOpenSSL>=0.15')
|
||||
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', version='2.13')
|
||||
certificate = CertificateSigningRequestInfoPyOpenSSL(module)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
certificate = CertificateSigningRequestInfoCryptography(module)
|
||||
|
||||
result = certificate.get_info()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
414
plugins/modules/openssl_dhparam.py
Normal file
414
plugins/modules/openssl_dhparam.py
Normal file
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2017, Thom Wiggers <ansible@thomwiggers.nl>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_dhparam
|
||||
short_description: Generate OpenSSL Diffie-Hellman Parameters
|
||||
description:
|
||||
- This module allows one to (re)generate OpenSSL DH-params.
|
||||
- This module uses file common arguments to specify generated file permissions.
|
||||
- "Please note that the module regenerates existing DH params if they don't
|
||||
match the module's options. If you are concerned that this could overwrite
|
||||
your existing DH params, consider using the I(backup) option."
|
||||
- The module can use the cryptography Python library, or the C(openssl) executable.
|
||||
By default, it tries to detect which one is available. This can be overridden
|
||||
with the I(select_crypto_backend) option.
|
||||
requirements:
|
||||
- Either cryptography >= 2.0
|
||||
- Or OpenSSL binary C(openssl)
|
||||
author:
|
||||
- Thom Wiggers (@thomwiggers)
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the parameters should exist or not,
|
||||
taking action if the state is different from what is stated.
|
||||
type: str
|
||||
default: present
|
||||
choices: [ absent, present ]
|
||||
size:
|
||||
description:
|
||||
- Size (in bits) of the generated DH-params.
|
||||
type: int
|
||||
default: 4096
|
||||
force:
|
||||
description:
|
||||
- Should the parameters be regenerated even it it already exists.
|
||||
type: bool
|
||||
default: no
|
||||
path:
|
||||
description:
|
||||
- Name of the file in which the generated parameters will be saved.
|
||||
type: path
|
||||
required: true
|
||||
backup:
|
||||
description:
|
||||
- Create a backup file including a timestamp so you can get the original
|
||||
DH params back if you overwrote them with new ones by accident.
|
||||
type: bool
|
||||
default: no
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(openssl).
|
||||
- If set to C(openssl), will try to use the OpenSSL C(openssl) executable.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, openssl ]
|
||||
return_content:
|
||||
description:
|
||||
- If set to C(yes), will return the (current or generated) DH params' content as I(dhparams).
|
||||
type: bool
|
||||
default: no
|
||||
extends_documentation_fragment:
|
||||
- files
|
||||
seealso:
|
||||
- module: openssl_certificate
|
||||
- module: openssl_csr
|
||||
- module: openssl_pkcs12
|
||||
- module: openssl_privatekey
|
||||
- module: openssl_publickey
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate Diffie-Hellman parameters with the default size (4096 bits)
|
||||
openssl_dhparam:
|
||||
path: /etc/ssl/dhparams.pem
|
||||
|
||||
- name: Generate DH Parameters with a different size (2048 bits)
|
||||
openssl_dhparam:
|
||||
path: /etc/ssl/dhparams.pem
|
||||
size: 2048
|
||||
|
||||
- name: Force regenerate an DH parameters if they already exist
|
||||
openssl_dhparam:
|
||||
path: /etc/ssl/dhparams.pem
|
||||
force: yes
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
size:
|
||||
description: Size (in bits) of the Diffie-Hellman parameters.
|
||||
returned: changed or success
|
||||
type: int
|
||||
sample: 4096
|
||||
filename:
|
||||
description: Path to the generated Diffie-Hellman parameters.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /etc/ssl/dhparams.pem
|
||||
backup_file:
|
||||
description: Name of backup file created.
|
||||
returned: changed and if I(backup) is C(yes)
|
||||
type: str
|
||||
sample: /path/to/dhparams.pem.2019-03-09@11:22~
|
||||
dhparams:
|
||||
description: The (current or generated) DH params' content.
|
||||
returned: if I(state) is C(present) and I(return_content) is C(yes)
|
||||
type: str
|
||||
version_added: "2.10"
|
||||
'''
|
||||
|
||||
import abc
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '2.0'
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.exceptions
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.hazmat.primitives.asymmetric.dh
|
||||
import cryptography.hazmat.primitives.serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
class DHParameterError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DHParameterBase(object):
|
||||
|
||||
def __init__(self, module):
|
||||
self.state = module.params['state']
|
||||
self.path = module.params['path']
|
||||
self.size = module.params['size']
|
||||
self.force = module.params['force']
|
||||
self.changed = False
|
||||
self.return_content = module.params['return_content']
|
||||
|
||||
self.backup = module.params['backup']
|
||||
self.backup_file = None
|
||||
|
||||
@abc.abstractmethod
|
||||
def _do_generate(self, module):
|
||||
"""Actually generate the DH params."""
|
||||
pass
|
||||
|
||||
def generate(self, module):
|
||||
"""Generate DH params."""
|
||||
changed = False
|
||||
|
||||
# ony generate when necessary
|
||||
if self.force or not self._check_params_valid(module):
|
||||
self._do_generate(module)
|
||||
changed = True
|
||||
|
||||
# fix permissions (checking force not necessary as done above)
|
||||
if not self._check_fs_attributes(module):
|
||||
# Fix done implicitly by
|
||||
# AnsibleModule.set_fs_attributes_if_different
|
||||
changed = True
|
||||
|
||||
self.changed = changed
|
||||
|
||||
def remove(self, module):
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
try:
|
||||
os.remove(self.path)
|
||||
self.changed = True
|
||||
except OSError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
def check(self, module):
|
||||
"""Ensure the resource is in its desired state."""
|
||||
if self.force:
|
||||
return False
|
||||
return self._check_params_valid(module) and self._check_fs_attributes(module)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _check_params_valid(self, module):
|
||||
"""Check if the params are in the correct state"""
|
||||
pass
|
||||
|
||||
def _check_fs_attributes(self, module):
|
||||
"""Checks (and changes if not in check mode!) fs attributes"""
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
attrs_changed = module.set_fs_attributes_if_different(file_args, False)
|
||||
|
||||
return not attrs_changed
|
||||
|
||||
def dump(self):
|
||||
"""Serialize the object into a dictionary."""
|
||||
|
||||
result = {
|
||||
'size': self.size,
|
||||
'filename': self.path,
|
||||
'changed': self.changed,
|
||||
}
|
||||
if self.backup_file:
|
||||
result['backup_file'] = self.backup_file
|
||||
if self.return_content:
|
||||
content = crypto_utils.load_file_if_exists(self.path, ignore_errors=True)
|
||||
result['dhparams'] = content.decode('utf-8') if content else None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DHParameterAbsent(DHParameterBase):
|
||||
|
||||
def __init__(self, module):
|
||||
super(DHParameterAbsent, self).__init__(module)
|
||||
|
||||
def _do_generate(self, module):
|
||||
"""Actually generate the DH params."""
|
||||
pass
|
||||
|
||||
def _check_params_valid(self, module):
|
||||
"""Check if the params are in the correct state"""
|
||||
pass
|
||||
|
||||
|
||||
class DHParameterOpenSSL(DHParameterBase):
|
||||
|
||||
def __init__(self, module):
|
||||
super(DHParameterOpenSSL, self).__init__(module)
|
||||
self.openssl_bin = module.get_bin_path('openssl', True)
|
||||
|
||||
def _do_generate(self, module):
|
||||
"""Actually generate the DH params."""
|
||||
# create a tempfile
|
||||
fd, tmpsrc = tempfile.mkstemp()
|
||||
os.close(fd)
|
||||
module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
|
||||
# openssl dhparam -out <path> <bits>
|
||||
command = [self.openssl_bin, 'dhparam', '-out', tmpsrc, str(self.size)]
|
||||
rc, dummy, err = module.run_command(command, check_rc=False)
|
||||
if rc != 0:
|
||||
raise DHParameterError(to_native(err))
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
try:
|
||||
module.atomic_move(tmpsrc, self.path)
|
||||
except Exception as e:
|
||||
module.fail_json(msg="Failed to write to file %s: %s" % (self.path, str(e)))
|
||||
|
||||
def _check_params_valid(self, module):
|
||||
"""Check if the params are in the correct state"""
|
||||
command = [self.openssl_bin, 'dhparam', '-check', '-text', '-noout', '-in', self.path]
|
||||
rc, out, err = module.run_command(command, check_rc=False)
|
||||
result = to_native(out)
|
||||
if rc != 0:
|
||||
# If the call failed the file probably doesn't exist or is
|
||||
# unreadable
|
||||
return False
|
||||
# output contains "(xxxx bit)"
|
||||
match = re.search(r"Parameters:\s+\((\d+) bit\).*", result)
|
||||
if not match:
|
||||
return False # No "xxxx bit" in output
|
||||
|
||||
bits = int(match.group(1))
|
||||
|
||||
# if output contains "WARNING" we've got a problem
|
||||
if "WARNING" in result or "WARNING" in to_native(err):
|
||||
return False
|
||||
|
||||
return bits == self.size
|
||||
|
||||
|
||||
class DHParameterCryptography(DHParameterBase):
|
||||
|
||||
def __init__(self, module):
|
||||
super(DHParameterCryptography, self).__init__(module)
|
||||
self.crypto_backend = cryptography.hazmat.backends.default_backend()
|
||||
|
||||
def _do_generate(self, module):
|
||||
"""Actually generate the DH params."""
|
||||
# Generate parameters
|
||||
params = cryptography.hazmat.primitives.asymmetric.dh.generate_parameters(
|
||||
generator=2,
|
||||
key_size=self.size,
|
||||
backend=self.crypto_backend,
|
||||
)
|
||||
# Serialize parameters
|
||||
result = params.parameter_bytes(
|
||||
encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM,
|
||||
format=cryptography.hazmat.primitives.serialization.ParameterFormat.PKCS3,
|
||||
)
|
||||
# Write result
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
crypto_utils.write_file(module, result)
|
||||
|
||||
def _check_params_valid(self, module):
|
||||
"""Check if the params are in the correct state"""
|
||||
# Load parameters
|
||||
try:
|
||||
with open(self.path, 'rb') as f:
|
||||
data = f.read()
|
||||
params = self.crypto_backend.load_pem_parameters(data)
|
||||
except Exception as dummy:
|
||||
return False
|
||||
# Check parameters
|
||||
bits = crypto_utils.count_bits(params.parameter_numbers().p)
|
||||
return bits == self.size
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type='str', default='present', choices=['absent', 'present']),
|
||||
size=dict(type='int', default=4096),
|
||||
force=dict(type='bool', default=False),
|
||||
path=dict(type='path', required=True),
|
||||
backup=dict(type='bool', default=False),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'openssl']),
|
||||
return_content=dict(type='bool', default=False),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
)
|
||||
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg="The directory '%s' does not exist or the file is not a directory" % base_dir
|
||||
)
|
||||
|
||||
if module.params['state'] == 'present':
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_openssl = module.get_bin_path('openssl', False) is not None
|
||||
|
||||
# First try cryptography, then OpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_openssl:
|
||||
backend = 'openssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect either the required Python library cryptography (>= {0}) "
|
||||
"or the OpenSSL binary openssl").format(MINIMAL_CRYPTOGRAPHY_VERSION))
|
||||
|
||||
if backend == 'openssl':
|
||||
dhparam = DHParameterOpenSSL(module)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
dhparam = DHParameterCryptography(module)
|
||||
|
||||
if module.check_mode:
|
||||
result = dhparam.dump()
|
||||
result['changed'] = module.params['force'] or not dhparam.check(module)
|
||||
module.exit_json(**result)
|
||||
|
||||
try:
|
||||
dhparam.generate(module)
|
||||
except DHParameterError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
else:
|
||||
dhparam = DHParameterAbsent(module)
|
||||
|
||||
if module.check_mode:
|
||||
result = dhparam.dump()
|
||||
result['changed'] = os.path.exists(module.params['path'])
|
||||
module.exit_json(**result)
|
||||
|
||||
if os.path.exists(module.params['path']):
|
||||
try:
|
||||
dhparam.remove(module)
|
||||
except Exception as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
result = dhparam.dump()
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
467
plugins/modules/openssl_pkcs12.py
Normal file
467
plugins/modules/openssl_pkcs12.py
Normal file
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2017, Guillaume Delpierre <gde@llew.me>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_pkcs12
|
||||
author:
|
||||
- Guillaume Delpierre (@gdelpierre)
|
||||
short_description: Generate OpenSSL PKCS#12 archive
|
||||
description:
|
||||
- This module allows one to (re-)generate PKCS#12.
|
||||
requirements:
|
||||
- python-pyOpenSSL
|
||||
options:
|
||||
action:
|
||||
description:
|
||||
- C(export) or C(parse) a PKCS#12.
|
||||
type: str
|
||||
default: export
|
||||
choices: [ export, parse ]
|
||||
other_certificates:
|
||||
description:
|
||||
- List of other certificates to include. Pre 2.8 this parameter was called C(ca_certificates)
|
||||
type: list
|
||||
elements: path
|
||||
aliases: [ ca_certificates ]
|
||||
certificate_path:
|
||||
description:
|
||||
- The path to read certificates and private keys from.
|
||||
- Must be in PEM format.
|
||||
type: path
|
||||
force:
|
||||
description:
|
||||
- Should the file be regenerated even if it already exists.
|
||||
type: bool
|
||||
default: no
|
||||
friendly_name:
|
||||
description:
|
||||
- Specifies the friendly name for the certificate and private key.
|
||||
type: str
|
||||
aliases: [ name ]
|
||||
iter_size:
|
||||
description:
|
||||
- Number of times to repeat the encryption step.
|
||||
type: int
|
||||
default: 2048
|
||||
maciter_size:
|
||||
description:
|
||||
- Number of times to repeat the MAC step.
|
||||
type: int
|
||||
default: 1
|
||||
passphrase:
|
||||
description:
|
||||
- The PKCS#12 password.
|
||||
type: str
|
||||
path:
|
||||
description:
|
||||
- Filename to write the PKCS#12 file to.
|
||||
type: path
|
||||
required: true
|
||||
privatekey_passphrase:
|
||||
description:
|
||||
- Passphrase source to decrypt any input private keys with.
|
||||
type: str
|
||||
privatekey_path:
|
||||
description:
|
||||
- File to read private key from.
|
||||
type: path
|
||||
state:
|
||||
description:
|
||||
- Whether the file should exist or not.
|
||||
All parameters except C(path) are ignored when state is C(absent).
|
||||
choices: [ absent, present ]
|
||||
default: present
|
||||
type: str
|
||||
src:
|
||||
description:
|
||||
- PKCS#12 file path to parse.
|
||||
type: path
|
||||
backup:
|
||||
description:
|
||||
- Create a backup file including a timestamp so you can get the original
|
||||
output file back if you overwrote it with a new one by accident.
|
||||
type: bool
|
||||
default: no
|
||||
return_content:
|
||||
description:
|
||||
- If set to C(yes), will return the (current or generated) PKCS#12's content as I(pkcs12).
|
||||
type: bool
|
||||
default: no
|
||||
extends_documentation_fragment:
|
||||
- files
|
||||
seealso:
|
||||
- module: openssl_certificate
|
||||
- module: openssl_csr
|
||||
- module: openssl_dhparam
|
||||
- module: openssl_privatekey
|
||||
- module: openssl_publickey
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate PKCS#12 file
|
||||
openssl_pkcs12:
|
||||
action: export
|
||||
path: /opt/certs/ansible.p12
|
||||
friendly_name: raclette
|
||||
privatekey_path: /opt/certs/keys/key.pem
|
||||
certificate_path: /opt/certs/cert.pem
|
||||
other_certificates: /opt/certs/ca.pem
|
||||
state: present
|
||||
|
||||
- name: Change PKCS#12 file permission
|
||||
openssl_pkcs12:
|
||||
action: export
|
||||
path: /opt/certs/ansible.p12
|
||||
friendly_name: raclette
|
||||
privatekey_path: /opt/certs/keys/key.pem
|
||||
certificate_path: /opt/certs/cert.pem
|
||||
other_certificates: /opt/certs/ca.pem
|
||||
state: present
|
||||
mode: '0600'
|
||||
|
||||
- name: Regen PKCS#12 file
|
||||
openssl_pkcs12:
|
||||
action: export
|
||||
src: /opt/certs/ansible.p12
|
||||
path: /opt/certs/ansible.p12
|
||||
friendly_name: raclette
|
||||
privatekey_path: /opt/certs/keys/key.pem
|
||||
certificate_path: /opt/certs/cert.pem
|
||||
other_certificates: /opt/certs/ca.pem
|
||||
state: present
|
||||
mode: '0600'
|
||||
force: yes
|
||||
|
||||
- name: Dump/Parse PKCS#12 file
|
||||
openssl_pkcs12:
|
||||
action: parse
|
||||
src: /opt/certs/ansible.p12
|
||||
path: /opt/certs/ansible.pem
|
||||
state: present
|
||||
|
||||
- name: Remove PKCS#12 file
|
||||
openssl_pkcs12:
|
||||
path: /opt/certs/ansible.p12
|
||||
state: absent
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
filename:
|
||||
description: Path to the generate PKCS#12 file.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /opt/certs/ansible.p12
|
||||
privatekey:
|
||||
description: Path to the TLS/SSL private key the public key was generated from.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /etc/ssl/private/ansible.com.pem
|
||||
backup_file:
|
||||
description: Name of backup file created.
|
||||
returned: changed and if I(backup) is C(yes)
|
||||
type: str
|
||||
sample: /path/to/ansible.com.pem.2019-03-09@11:22~
|
||||
pkcs12:
|
||||
description: The (current or generated) PKCS#12's content Base64 encoded.
|
||||
returned: if I(state) is C(present) and I(return_content) is C(yes)
|
||||
type: str
|
||||
version_added: "2.10"
|
||||
'''
|
||||
|
||||
import base64
|
||||
import stat
|
||||
import os
|
||||
import traceback
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
from OpenSSL import crypto
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
pyopenssl_found = False
|
||||
else:
|
||||
pyopenssl_found = True
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils._text import to_bytes, to_native
|
||||
|
||||
|
||||
class PkcsError(crypto_utils.OpenSSLObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class Pkcs(crypto_utils.OpenSSLObject):
|
||||
|
||||
def __init__(self, module):
|
||||
super(Pkcs, self).__init__(
|
||||
module.params['path'],
|
||||
module.params['state'],
|
||||
module.params['force'],
|
||||
module.check_mode
|
||||
)
|
||||
self.action = module.params['action']
|
||||
self.other_certificates = module.params['other_certificates']
|
||||
self.certificate_path = module.params['certificate_path']
|
||||
self.friendly_name = module.params['friendly_name']
|
||||
self.iter_size = module.params['iter_size']
|
||||
self.maciter_size = module.params['maciter_size']
|
||||
self.passphrase = module.params['passphrase']
|
||||
self.pkcs12 = None
|
||||
self.privatekey_passphrase = module.params['privatekey_passphrase']
|
||||
self.privatekey_path = module.params['privatekey_path']
|
||||
self.pkcs12_bytes = None
|
||||
self.return_content = module.params['return_content']
|
||||
self.src = module.params['src']
|
||||
|
||||
if module.params['mode'] is None:
|
||||
module.params['mode'] = '0400'
|
||||
|
||||
self.backup = module.params['backup']
|
||||
self.backup_file = None
|
||||
|
||||
def check(self, module, perms_required=True):
|
||||
"""Ensure the resource is in its desired state."""
|
||||
|
||||
state_and_perms = super(Pkcs, self).check(module, perms_required)
|
||||
|
||||
def _check_pkey_passphrase():
|
||||
if self.privatekey_passphrase:
|
||||
try:
|
||||
crypto_utils.load_privatekey(self.privatekey_path,
|
||||
self.privatekey_passphrase)
|
||||
except crypto.Error:
|
||||
return False
|
||||
except crypto_utils.OpenSSLBadPassphraseError:
|
||||
return False
|
||||
return True
|
||||
|
||||
if not state_and_perms:
|
||||
return state_and_perms
|
||||
|
||||
if os.path.exists(self.path) and module.params['action'] == 'export':
|
||||
dummy = self.generate(module)
|
||||
self.src = self.path
|
||||
try:
|
||||
pkcs12_privatekey, pkcs12_certificate, pkcs12_other_certificates, pkcs12_friendly_name = self.parse()
|
||||
except crypto.Error:
|
||||
return False
|
||||
if (pkcs12_privatekey is not None) and (self.privatekey_path is not None):
|
||||
expected_pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM,
|
||||
self.pkcs12.get_privatekey())
|
||||
if pkcs12_privatekey != expected_pkey:
|
||||
return False
|
||||
elif bool(pkcs12_privatekey) != bool(self.privatekey_path):
|
||||
return False
|
||||
|
||||
if (pkcs12_certificate is not None) and (self.certificate_path is not None):
|
||||
|
||||
expected_cert = crypto.dump_certificate(crypto.FILETYPE_PEM,
|
||||
self.pkcs12.get_certificate())
|
||||
if pkcs12_certificate != expected_cert:
|
||||
return False
|
||||
elif bool(pkcs12_certificate) != bool(self.certificate_path):
|
||||
return False
|
||||
|
||||
if (pkcs12_other_certificates is not None) and (self.other_certificates is not None):
|
||||
expected_other_certs = [crypto.dump_certificate(crypto.FILETYPE_PEM,
|
||||
other_cert) for other_cert in self.pkcs12.get_ca_certificates()]
|
||||
if set(pkcs12_other_certificates) != set(expected_other_certs):
|
||||
return False
|
||||
elif bool(pkcs12_other_certificates) != bool(self.other_certificates):
|
||||
return False
|
||||
|
||||
if pkcs12_privatekey:
|
||||
# This check is required because pyOpenSSL will not return a friendly name
|
||||
# if the private key is not set in the file
|
||||
if ((self.pkcs12.get_friendlyname() is not None) and (pkcs12_friendly_name is not None)):
|
||||
if self.pkcs12.get_friendlyname() != pkcs12_friendly_name:
|
||||
return False
|
||||
elif bool(self.pkcs12.get_friendlyname()) != bool(pkcs12_friendly_name):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
return _check_pkey_passphrase()
|
||||
|
||||
def dump(self):
|
||||
"""Serialize the object into a dictionary."""
|
||||
|
||||
result = {
|
||||
'filename': self.path,
|
||||
}
|
||||
if self.privatekey_path:
|
||||
result['privatekey_path'] = self.privatekey_path
|
||||
if self.backup_file:
|
||||
result['backup_file'] = self.backup_file
|
||||
if self.return_content:
|
||||
if self.pkcs12_bytes is None:
|
||||
self.pkcs12_bytes = crypto_utils.load_file_if_exists(self.path, ignore_errors=True)
|
||||
result['pkcs12'] = base64.b64encode(self.pkcs12_bytes) if self.pkcs12_bytes else None
|
||||
|
||||
return result
|
||||
|
||||
def generate(self, module):
|
||||
"""Generate PKCS#12 file archive."""
|
||||
self.pkcs12 = crypto.PKCS12()
|
||||
|
||||
if self.other_certificates:
|
||||
other_certs = [crypto_utils.load_certificate(other_cert) for other_cert
|
||||
in self.other_certificates]
|
||||
self.pkcs12.set_ca_certificates(other_certs)
|
||||
|
||||
if self.certificate_path:
|
||||
self.pkcs12.set_certificate(crypto_utils.load_certificate(
|
||||
self.certificate_path))
|
||||
|
||||
if self.friendly_name:
|
||||
self.pkcs12.set_friendlyname(to_bytes(self.friendly_name))
|
||||
|
||||
if self.privatekey_path:
|
||||
try:
|
||||
self.pkcs12.set_privatekey(crypto_utils.load_privatekey(
|
||||
self.privatekey_path,
|
||||
self.privatekey_passphrase)
|
||||
)
|
||||
except crypto_utils.OpenSSLBadPassphraseError as exc:
|
||||
raise PkcsError(exc)
|
||||
|
||||
return self.pkcs12.export(self.passphrase, self.iter_size, self.maciter_size)
|
||||
|
||||
def remove(self, module):
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
super(Pkcs, self).remove(module)
|
||||
|
||||
def parse(self):
|
||||
"""Read PKCS#12 file."""
|
||||
|
||||
try:
|
||||
with open(self.src, 'rb') as pkcs12_fh:
|
||||
pkcs12_content = pkcs12_fh.read()
|
||||
p12 = crypto.load_pkcs12(pkcs12_content,
|
||||
self.passphrase)
|
||||
pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM,
|
||||
p12.get_privatekey())
|
||||
crt = crypto.dump_certificate(crypto.FILETYPE_PEM,
|
||||
p12.get_certificate())
|
||||
other_certs = []
|
||||
if p12.get_ca_certificates() is not None:
|
||||
other_certs = [crypto.dump_certificate(crypto.FILETYPE_PEM,
|
||||
other_cert) for other_cert in p12.get_ca_certificates()]
|
||||
|
||||
friendly_name = p12.get_friendlyname()
|
||||
|
||||
return (pkey, crt, other_certs, friendly_name)
|
||||
|
||||
except IOError as exc:
|
||||
raise PkcsError(exc)
|
||||
|
||||
def write(self, module, content, mode=None):
|
||||
"""Write the PKCS#12 file."""
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
crypto_utils.write_file(module, content, mode)
|
||||
if self.return_content:
|
||||
self.pkcs12_bytes = content
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = dict(
|
||||
action=dict(type='str', default='export', choices=['export', 'parse']),
|
||||
other_certificates=dict(type='list', elements='path', aliases=['ca_certificates']),
|
||||
certificate_path=dict(type='path'),
|
||||
force=dict(type='bool', default=False),
|
||||
friendly_name=dict(type='str', aliases=['name']),
|
||||
iter_size=dict(type='int', default=2048),
|
||||
maciter_size=dict(type='int', default=1),
|
||||
passphrase=dict(type='str', no_log=True),
|
||||
path=dict(type='path', required=True),
|
||||
privatekey_passphrase=dict(type='str', no_log=True),
|
||||
privatekey_path=dict(type='path'),
|
||||
state=dict(type='str', default='present', choices=['absent', 'present']),
|
||||
src=dict(type='path'),
|
||||
backup=dict(type='bool', default=False),
|
||||
return_content=dict(type='bool', default=False),
|
||||
)
|
||||
|
||||
required_if = [
|
||||
['action', 'parse', ['src']],
|
||||
]
|
||||
|
||||
module = AnsibleModule(
|
||||
add_file_common_args=True,
|
||||
argument_spec=argument_spec,
|
||||
required_if=required_if,
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
if not pyopenssl_found:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL'), exception=PYOPENSSL_IMP_ERR)
|
||||
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg="The directory '%s' does not exist or the path is not a directory" % base_dir
|
||||
)
|
||||
|
||||
try:
|
||||
pkcs12 = Pkcs(module)
|
||||
changed = False
|
||||
|
||||
if module.params['state'] == 'present':
|
||||
if module.check_mode:
|
||||
result = pkcs12.dump()
|
||||
result['changed'] = module.params['force'] or not pkcs12.check(module)
|
||||
module.exit_json(**result)
|
||||
|
||||
if not pkcs12.check(module, perms_required=False) or module.params['force']:
|
||||
if module.params['action'] == 'export':
|
||||
if not module.params['friendly_name']:
|
||||
module.fail_json(msg='Friendly_name is required')
|
||||
pkcs12_content = pkcs12.generate(module)
|
||||
pkcs12.write(module, pkcs12_content, 0o600)
|
||||
changed = True
|
||||
else:
|
||||
pkey, cert, other_certs, friendly_name = pkcs12.parse()
|
||||
dump_content = '%s%s%s' % (to_native(pkey), to_native(cert), to_native(b''.join(other_certs)))
|
||||
pkcs12.write(module, to_bytes(dump_content))
|
||||
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
if module.set_fs_attributes_if_different(file_args, changed):
|
||||
changed = True
|
||||
else:
|
||||
if module.check_mode:
|
||||
result = pkcs12.dump()
|
||||
result['changed'] = os.path.exists(module.params['path'])
|
||||
module.exit_json(**result)
|
||||
|
||||
if os.path.exists(module.params['path']):
|
||||
pkcs12.remove(module)
|
||||
changed = True
|
||||
|
||||
result = pkcs12.dump()
|
||||
result['changed'] = changed
|
||||
if os.path.exists(module.params['path']):
|
||||
file_mode = "%04o" % stat.S_IMODE(os.stat(module.params['path']).st_mode)
|
||||
result['mode'] = file_mode
|
||||
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
933
plugins/modules/openssl_privatekey.py
Normal file
933
plugins/modules/openssl_privatekey.py
Normal file
@@ -0,0 +1,933 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_privatekey
|
||||
short_description: Generate OpenSSL private keys
|
||||
description:
|
||||
- This module allows one to (re)generate OpenSSL private keys.
|
||||
- One can generate L(RSA,https://en.wikipedia.org/wiki/RSA_%28cryptosystem%29),
|
||||
L(DSA,https://en.wikipedia.org/wiki/Digital_Signature_Algorithm),
|
||||
L(ECC,https://en.wikipedia.org/wiki/Elliptic-curve_cryptography) or
|
||||
L(EdDSA,https://en.wikipedia.org/wiki/EdDSA) private keys.
|
||||
- Keys are generated in PEM format.
|
||||
- "Please note that the module regenerates private keys if they don't match
|
||||
the module's options. In particular, if you provide another passphrase
|
||||
(or specify none), change the keysize, etc., the private key will be
|
||||
regenerated. If you are concerned that this could **overwrite your private key**,
|
||||
consider using the I(backup) option."
|
||||
- The module can use the cryptography Python library, or the pyOpenSSL Python
|
||||
library. By default, it tries to detect which one is available. This can be
|
||||
overridden with the I(select_crypto_backend) option. Please note that the
|
||||
PyOpenSSL backend was deprecated in Ansible 2.9 and will be removed in Ansible 2.13."
|
||||
requirements:
|
||||
- Either cryptography >= 1.2.3 (older versions might work as well)
|
||||
- Or pyOpenSSL
|
||||
author:
|
||||
- Yanis Guenane (@Spredzy)
|
||||
- Felix Fontein (@felixfontein)
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the private key should exist or not, taking action if the state is different from what is stated.
|
||||
type: str
|
||||
default: present
|
||||
choices: [ absent, present ]
|
||||
size:
|
||||
description:
|
||||
- Size (in bits) of the TLS/SSL key to generate.
|
||||
type: int
|
||||
default: 4096
|
||||
type:
|
||||
description:
|
||||
- The algorithm used to generate the TLS/SSL private key.
|
||||
- Note that C(ECC), C(X25519), C(X448), C(Ed25519) and C(Ed448) require the C(cryptography) backend.
|
||||
C(X25519) needs cryptography 2.5 or newer, while C(X448), C(Ed25519) and C(Ed448) require
|
||||
cryptography 2.6 or newer. For C(ECC), the minimal cryptography version required depends on the
|
||||
I(curve) option.
|
||||
type: str
|
||||
default: RSA
|
||||
choices: [ DSA, ECC, Ed25519, Ed448, RSA, X25519, X448 ]
|
||||
curve:
|
||||
description:
|
||||
- Note that not all curves are supported by all versions of C(cryptography).
|
||||
- For maximal interoperability, C(secp384r1) or C(secp256r1) should be used.
|
||||
- We use the curve names as defined in the
|
||||
L(IANA registry for TLS,https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8).
|
||||
type: str
|
||||
choices:
|
||||
- secp384r1
|
||||
- secp521r1
|
||||
- secp224r1
|
||||
- secp192r1
|
||||
- secp256r1
|
||||
- secp256k1
|
||||
- brainpoolP256r1
|
||||
- brainpoolP384r1
|
||||
- brainpoolP512r1
|
||||
- sect571k1
|
||||
- sect409k1
|
||||
- sect283k1
|
||||
- sect233k1
|
||||
- sect163k1
|
||||
- sect571r1
|
||||
- sect409r1
|
||||
- sect283r1
|
||||
- sect233r1
|
||||
- sect163r2
|
||||
force:
|
||||
description:
|
||||
- Should the key be regenerated even if it already exists.
|
||||
type: bool
|
||||
default: no
|
||||
path:
|
||||
description:
|
||||
- Name of the file in which the generated TLS/SSL private key will be written. It will have 0600 mode.
|
||||
type: path
|
||||
required: true
|
||||
passphrase:
|
||||
description:
|
||||
- The passphrase for the private key.
|
||||
type: str
|
||||
cipher:
|
||||
description:
|
||||
- The cipher to encrypt the private key. (Valid values can be found by
|
||||
running `openssl list -cipher-algorithms` or `openssl list-cipher-algorithms`,
|
||||
depending on your OpenSSL version.)
|
||||
- When using the C(cryptography) backend, use C(auto).
|
||||
type: str
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
- Please note that the C(pyopenssl) backend has been deprecated in Ansible 2.9, and will be removed in Ansible 2.13.
|
||||
From that point on, only the C(cryptography) backend will be available.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
format:
|
||||
description:
|
||||
- Determines which format the private key is written in. By default, PKCS1 (traditional OpenSSL format)
|
||||
is used for all keys which support it. Please note that not every key can be exported in any format.
|
||||
- The value C(auto) selects a fromat based on the key format. The value C(auto_ignore) does the same,
|
||||
but for existing private key files, it will not force a regenerate when its format is not the automatically
|
||||
selected one for generation.
|
||||
- Note that if the format for an existing private key mismatches, the key is *regenerated* by default.
|
||||
To change this behavior, use the I(format_mismatch) option.
|
||||
- The I(format) option is only supported by the C(cryptography) backend. The C(pyopenssl) backend will
|
||||
fail if a value different from C(auto_ignore) is used.
|
||||
type: str
|
||||
default: auto_ignore
|
||||
choices: [ pkcs1, pkcs8, raw, auto, auto_ignore ]
|
||||
format_mismatch:
|
||||
description:
|
||||
- Determines behavior of the module if the format of a private key does not match the expected format, but all
|
||||
other parameters are as expected.
|
||||
- If set to C(regenerate) (default), generates a new private key.
|
||||
- If set to C(convert), the key will be converted to the new format instead.
|
||||
- Only supported by the C(cryptography) backend.
|
||||
type: str
|
||||
default: regenerate
|
||||
choices: [ regenerate, convert ]
|
||||
backup:
|
||||
description:
|
||||
- Create a backup file including a timestamp so you can get
|
||||
the original private key back if you overwrote it with a new one by accident.
|
||||
type: bool
|
||||
default: no
|
||||
return_content:
|
||||
description:
|
||||
- If set to C(yes), will return the (current or generated) private key's content as I(privatekey).
|
||||
- Note that especially if the private key is not encrypted, you have to make sure that the returned
|
||||
value is treated appropriately and not accidentally written to logs etc.! Use with care!
|
||||
type: bool
|
||||
default: no
|
||||
regenerate:
|
||||
description:
|
||||
- Allows to configure in which situations the module is allowed to regenerate private keys.
|
||||
The module will always generate a new key if the destination file does not exist.
|
||||
- By default, the key will be regenerated when it doesn't match the module's options,
|
||||
except when the key cannot be read or the passphrase does not match. Please note that
|
||||
this B(changed) for Ansible 2.10. For Ansible 2.9, the behavior was as if C(full_idempotence)
|
||||
is specified.
|
||||
- If set to C(never), the module will fail if the key cannot be read or the passphrase
|
||||
isn't matching, and will never regenerate an existing key.
|
||||
- If set to C(fail), the module will fail if the key does not correspond to the module's
|
||||
options.
|
||||
- If set to C(partial_idempotence), the key will be regenerated if it does not conform to
|
||||
the module's options. The key is B(not) regenerated if it cannot be read (broken file),
|
||||
the key is protected by an unknown passphrase, or when they key is not protected by a
|
||||
passphrase, but a passphrase is specified.
|
||||
- If set to C(full_idempotence), the key will be regenerated if it does not conform to the
|
||||
module's options. This is also the case if the key cannot be read (broken file), the key
|
||||
is protected by an unknown passphrase, or when they key is not protected by a passphrase,
|
||||
but a passphrase is specified. Make sure you have a B(backup) when using this option!
|
||||
- If set to C(always), the module will always regenerate the key. This is equivalent to
|
||||
setting I(force) to C(yes).
|
||||
- Note that if I(format_mismatch) is set to C(convert) and everything matches except the
|
||||
format, the key will always be converted, except if I(regenerate) is set to C(always).
|
||||
type: str
|
||||
choices:
|
||||
- never
|
||||
- fail
|
||||
- partial_idempotence
|
||||
- full_idempotence
|
||||
- always
|
||||
default: full_idempotence
|
||||
extends_documentation_fragment:
|
||||
- files
|
||||
seealso:
|
||||
- module: openssl_certificate
|
||||
- module: openssl_csr
|
||||
- module: openssl_dhparam
|
||||
- module: openssl_pkcs12
|
||||
- module: openssl_publickey
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate an OpenSSL private key with the default values (4096 bits, RSA)
|
||||
openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
|
||||
- name: Generate an OpenSSL private key with the default values (4096 bits, RSA) and a passphrase
|
||||
openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
passphrase: ansible
|
||||
cipher: aes256
|
||||
|
||||
- name: Generate an OpenSSL private key with a different size (2048 bits)
|
||||
openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
size: 2048
|
||||
|
||||
- name: Force regenerate an OpenSSL private key if it already exists
|
||||
openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
force: yes
|
||||
|
||||
- name: Generate an OpenSSL private key with a different algorithm (DSA)
|
||||
openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
type: DSA
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
size:
|
||||
description: Size (in bits) of the TLS/SSL private key.
|
||||
returned: changed or success
|
||||
type: int
|
||||
sample: 4096
|
||||
type:
|
||||
description: Algorithm used to generate the TLS/SSL private key.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: RSA
|
||||
curve:
|
||||
description: Elliptic curve used to generate the TLS/SSL private key.
|
||||
returned: changed or success, and I(type) is C(ECC)
|
||||
type: str
|
||||
sample: secp256r1
|
||||
filename:
|
||||
description: Path to the generated TLS/SSL private key file.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /etc/ssl/private/ansible.com.pem
|
||||
fingerprint:
|
||||
description:
|
||||
- The fingerprint of the public key. Fingerprint will be generated for each C(hashlib.algorithms) available.
|
||||
- The PyOpenSSL backend requires PyOpenSSL >= 16.0 for meaningful output.
|
||||
returned: changed or success
|
||||
type: dict
|
||||
sample:
|
||||
md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29"
|
||||
sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10"
|
||||
sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46"
|
||||
sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7"
|
||||
sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d"
|
||||
sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b"
|
||||
backup_file:
|
||||
description: Name of backup file created.
|
||||
returned: changed and if I(backup) is C(yes)
|
||||
type: str
|
||||
sample: /path/to/privatekey.pem.2019-03-09@11:22~
|
||||
privatekey:
|
||||
description:
|
||||
- The (current or generated) private key's content.
|
||||
- Will be Base64-encoded if the key is in raw format.
|
||||
returned: if I(state) is C(present) and I(return_content) is C(yes)
|
||||
type: str
|
||||
version_added: "2.10"
|
||||
'''
|
||||
|
||||
import abc
|
||||
import base64
|
||||
import os
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.6'
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.exceptions
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.hazmat.primitives.serialization
|
||||
import cryptography.hazmat.primitives.asymmetric.rsa
|
||||
import cryptography.hazmat.primitives.asymmetric.dsa
|
||||
import cryptography.hazmat.primitives.asymmetric.ec
|
||||
import cryptography.hazmat.primitives.asymmetric.utils
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto import (
|
||||
CRYPTOGRAPHY_HAS_X25519,
|
||||
CRYPTOGRAPHY_HAS_X25519_FULL,
|
||||
CRYPTOGRAPHY_HAS_X448,
|
||||
CRYPTOGRAPHY_HAS_ED25519,
|
||||
CRYPTOGRAPHY_HAS_ED448,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils._text import to_native, to_bytes
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
|
||||
|
||||
class PrivateKeyError(crypto_utils.OpenSSLObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class PrivateKeyBase(crypto_utils.OpenSSLObject):
|
||||
|
||||
def __init__(self, module):
|
||||
super(PrivateKeyBase, self).__init__(
|
||||
module.params['path'],
|
||||
module.params['state'],
|
||||
module.params['force'],
|
||||
module.check_mode
|
||||
)
|
||||
self.size = module.params['size']
|
||||
self.passphrase = module.params['passphrase']
|
||||
self.cipher = module.params['cipher']
|
||||
self.privatekey = None
|
||||
self.fingerprint = {}
|
||||
self.format = module.params['format']
|
||||
self.format_mismatch = module.params['format_mismatch']
|
||||
self.privatekey_bytes = None
|
||||
self.return_content = module.params['return_content']
|
||||
self.regenerate = module.params['regenerate']
|
||||
if self.regenerate == 'always':
|
||||
self.force = True
|
||||
|
||||
self.backup = module.params['backup']
|
||||
self.backup_file = None
|
||||
|
||||
if module.params['mode'] is None:
|
||||
module.params['mode'] = '0600'
|
||||
|
||||
@abc.abstractmethod
|
||||
def _generate_private_key(self):
|
||||
"""(Re-)Generate private key."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _ensure_private_key_loaded(self):
|
||||
"""Make sure that the private key has been loaded."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_private_key_data(self):
|
||||
"""Return bytes for self.privatekey"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_fingerprint(self):
|
||||
pass
|
||||
|
||||
def generate(self, module):
|
||||
"""Generate a keypair."""
|
||||
|
||||
if not self.check(module, perms_required=False, ignore_conversion=True) or self.force:
|
||||
# Regenerate
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
self._generate_private_key()
|
||||
privatekey_data = self._get_private_key_data()
|
||||
if self.return_content:
|
||||
self.privatekey_bytes = privatekey_data
|
||||
crypto_utils.write_file(module, privatekey_data, 0o600)
|
||||
self.changed = True
|
||||
elif not self.check(module, perms_required=False, ignore_conversion=False):
|
||||
# Convert
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
self._ensure_private_key_loaded()
|
||||
privatekey_data = self._get_private_key_data()
|
||||
if self.return_content:
|
||||
self.privatekey_bytes = privatekey_data
|
||||
crypto_utils.write_file(module, privatekey_data, 0o600)
|
||||
self.changed = True
|
||||
|
||||
self.fingerprint = self._get_fingerprint()
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
if module.set_fs_attributes_if_different(file_args, False):
|
||||
self.changed = True
|
||||
|
||||
def remove(self, module):
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
super(PrivateKeyBase, self).remove(module)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _check_passphrase(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _check_size_and_type(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _check_format(self):
|
||||
pass
|
||||
|
||||
def check(self, module, perms_required=True, ignore_conversion=True):
|
||||
"""Ensure the resource is in its desired state."""
|
||||
|
||||
state_and_perms = super(PrivateKeyBase, self).check(module, perms_required=False)
|
||||
|
||||
if not state_and_perms:
|
||||
# key does not exist
|
||||
return False
|
||||
|
||||
if not self._check_passphrase():
|
||||
if self.regenerate in ('full_idempotence', 'always'):
|
||||
return False
|
||||
module.fail_json(msg='Unable to read the key. The key is protected with a another passphrase / no passphrase or broken.'
|
||||
' Will not proceed. To force regeneration, call the module with `generate`'
|
||||
' set to `full_idempotence` or `always`, or with `force=yes`.')
|
||||
|
||||
if self.regenerate != 'never':
|
||||
if not self._check_size_and_type():
|
||||
if self.regenerate in ('partial_idempotence', 'full_idempotence', 'always'):
|
||||
return False
|
||||
module.fail_json(msg='Key has wrong type and/or size.'
|
||||
' Will not proceed. To force regeneration, call the module with `generate`'
|
||||
' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=yes`.')
|
||||
|
||||
if not self._check_format():
|
||||
# During conversion step, convert if format does not match and format_mismatch == 'convert'
|
||||
if not ignore_conversion and self.format_mismatch == 'convert':
|
||||
return False
|
||||
# During generation step, regenerate if format does not match and format_mismatch == 'regenerate'
|
||||
if ignore_conversion and self.format_mismatch == 'regenerate' and self.regenerate != 'never':
|
||||
if not ignore_conversion or self.regenerate in ('partial_idempotence', 'full_idempotence', 'always'):
|
||||
return False
|
||||
module.fail_json(msg='Key has wrong format.'
|
||||
' Will not proceed. To force regeneration, call the module with `generate`'
|
||||
' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=yes`.'
|
||||
' To convert the key, set `format_mismatch` to `convert`.')
|
||||
|
||||
# check whether permissions are correct (in case that needs to be checked)
|
||||
return not perms_required or super(PrivateKeyBase, self).check(module, perms_required=perms_required)
|
||||
|
||||
def dump(self):
|
||||
"""Serialize the object into a dictionary."""
|
||||
|
||||
result = {
|
||||
'size': self.size,
|
||||
'filename': self.path,
|
||||
'changed': self.changed,
|
||||
'fingerprint': self.fingerprint,
|
||||
}
|
||||
if self.backup_file:
|
||||
result['backup_file'] = self.backup_file
|
||||
if self.return_content:
|
||||
if self.privatekey_bytes is None:
|
||||
self.privatekey_bytes = crypto_utils.load_file_if_exists(self.path, ignore_errors=True)
|
||||
if self.privatekey_bytes:
|
||||
if crypto_utils.identify_private_key_format(self.privatekey_bytes) == 'raw':
|
||||
result['privatekey'] = base64.b64encode(self.privatekey_bytes)
|
||||
else:
|
||||
result['privatekey'] = self.privatekey_bytes.decode('utf-8')
|
||||
else:
|
||||
result['privatekey'] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Implementation with using pyOpenSSL
|
||||
class PrivateKeyPyOpenSSL(PrivateKeyBase):
|
||||
|
||||
def __init__(self, module):
|
||||
super(PrivateKeyPyOpenSSL, self).__init__(module)
|
||||
|
||||
if module.params['type'] == 'RSA':
|
||||
self.type = crypto.TYPE_RSA
|
||||
elif module.params['type'] == 'DSA':
|
||||
self.type = crypto.TYPE_DSA
|
||||
else:
|
||||
module.fail_json(msg="PyOpenSSL backend only supports RSA and DSA keys.")
|
||||
|
||||
if self.format != 'auto_ignore':
|
||||
module.fail_json(msg="PyOpenSSL backend only supports auto_ignore format.")
|
||||
|
||||
def _generate_private_key(self):
|
||||
"""(Re-)Generate private key."""
|
||||
self.privatekey = crypto.PKey()
|
||||
try:
|
||||
self.privatekey.generate_key(self.type, self.size)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PrivateKeyError(exc)
|
||||
|
||||
def _ensure_private_key_loaded(self):
|
||||
"""Make sure that the private key has been loaded."""
|
||||
if self.privatekey is None:
|
||||
try:
|
||||
self.privatekey = privatekey = crypto_utils.load_privatekey(self.path, self.passphrase)
|
||||
except crypto_utils.OpenSSLBadPassphraseError as exc:
|
||||
raise PrivateKeyError(exc)
|
||||
|
||||
def _get_private_key_data(self):
|
||||
"""Return bytes for self.privatekey"""
|
||||
if self.cipher and self.passphrase:
|
||||
return crypto.dump_privatekey(crypto.FILETYPE_PEM, self.privatekey,
|
||||
self.cipher, to_bytes(self.passphrase))
|
||||
else:
|
||||
return crypto.dump_privatekey(crypto.FILETYPE_PEM, self.privatekey)
|
||||
|
||||
def _get_fingerprint(self):
|
||||
return crypto_utils.get_fingerprint(self.path, self.passphrase)
|
||||
|
||||
def _check_passphrase(self):
|
||||
try:
|
||||
crypto_utils.load_privatekey(self.path, self.passphrase)
|
||||
return True
|
||||
except Exception as dummy:
|
||||
return False
|
||||
|
||||
def _check_size_and_type(self):
|
||||
def _check_size(privatekey):
|
||||
return self.size == privatekey.bits()
|
||||
|
||||
def _check_type(privatekey):
|
||||
return self.type == privatekey.type()
|
||||
|
||||
self._ensure_private_key_loaded()
|
||||
return _check_size(self.privatekey) and _check_type(self.privatekey)
|
||||
|
||||
def _check_format(self):
|
||||
# Not supported by this backend
|
||||
return True
|
||||
|
||||
def dump(self):
|
||||
"""Serialize the object into a dictionary."""
|
||||
|
||||
result = super(PrivateKeyPyOpenSSL, self).dump()
|
||||
|
||||
if self.type == crypto.TYPE_RSA:
|
||||
result['type'] = 'RSA'
|
||||
else:
|
||||
result['type'] = 'DSA'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Implementation with using cryptography
|
||||
class PrivateKeyCryptography(PrivateKeyBase):
|
||||
|
||||
def _get_ec_class(self, ectype):
|
||||
ecclass = cryptography.hazmat.primitives.asymmetric.ec.__dict__.get(ectype)
|
||||
if ecclass is None:
|
||||
self.module.fail_json(msg='Your cryptography version does not support {0}'.format(ectype))
|
||||
return ecclass
|
||||
|
||||
def _add_curve(self, name, ectype, deprecated=False):
|
||||
def create(size):
|
||||
ecclass = self._get_ec_class(ectype)
|
||||
return ecclass()
|
||||
|
||||
def verify(privatekey):
|
||||
ecclass = self._get_ec_class(ectype)
|
||||
return isinstance(privatekey.private_numbers().public_numbers.curve, ecclass)
|
||||
|
||||
self.curves[name] = {
|
||||
'create': create,
|
||||
'verify': verify,
|
||||
'deprecated': deprecated,
|
||||
}
|
||||
|
||||
def __init__(self, module):
|
||||
super(PrivateKeyCryptography, self).__init__(module)
|
||||
|
||||
self.curves = dict()
|
||||
self._add_curve('secp384r1', 'SECP384R1')
|
||||
self._add_curve('secp521r1', 'SECP521R1')
|
||||
self._add_curve('secp224r1', 'SECP224R1')
|
||||
self._add_curve('secp192r1', 'SECP192R1')
|
||||
self._add_curve('secp256r1', 'SECP256R1')
|
||||
self._add_curve('secp256k1', 'SECP256K1')
|
||||
self._add_curve('brainpoolP256r1', 'BrainpoolP256R1', deprecated=True)
|
||||
self._add_curve('brainpoolP384r1', 'BrainpoolP384R1', deprecated=True)
|
||||
self._add_curve('brainpoolP512r1', 'BrainpoolP512R1', deprecated=True)
|
||||
self._add_curve('sect571k1', 'SECT571K1', deprecated=True)
|
||||
self._add_curve('sect409k1', 'SECT409K1', deprecated=True)
|
||||
self._add_curve('sect283k1', 'SECT283K1', deprecated=True)
|
||||
self._add_curve('sect233k1', 'SECT233K1', deprecated=True)
|
||||
self._add_curve('sect163k1', 'SECT163K1', deprecated=True)
|
||||
self._add_curve('sect571r1', 'SECT571R1', deprecated=True)
|
||||
self._add_curve('sect409r1', 'SECT409R1', deprecated=True)
|
||||
self._add_curve('sect283r1', 'SECT283R1', deprecated=True)
|
||||
self._add_curve('sect233r1', 'SECT233R1', deprecated=True)
|
||||
self._add_curve('sect163r2', 'SECT163R2', deprecated=True)
|
||||
|
||||
self.module = module
|
||||
self.cryptography_backend = cryptography.hazmat.backends.default_backend()
|
||||
|
||||
self.type = module.params['type']
|
||||
self.curve = module.params['curve']
|
||||
if not CRYPTOGRAPHY_HAS_X25519 and self.type == 'X25519':
|
||||
self.module.fail_json(msg='Your cryptography version does not support X25519')
|
||||
if not CRYPTOGRAPHY_HAS_X25519_FULL and self.type == 'X25519':
|
||||
self.module.fail_json(msg='Your cryptography version does not support X25519 serialization')
|
||||
if not CRYPTOGRAPHY_HAS_X448 and self.type == 'X448':
|
||||
self.module.fail_json(msg='Your cryptography version does not support X448')
|
||||
if not CRYPTOGRAPHY_HAS_ED25519 and self.type == 'Ed25519':
|
||||
self.module.fail_json(msg='Your cryptography version does not support Ed25519')
|
||||
if not CRYPTOGRAPHY_HAS_ED448 and self.type == 'Ed448':
|
||||
self.module.fail_json(msg='Your cryptography version does not support Ed448')
|
||||
|
||||
def _get_wanted_format(self):
|
||||
if self.format not in ('auto', 'auto_ignore'):
|
||||
return self.format
|
||||
if self.type in ('X25519', 'X448', 'Ed25519', 'Ed448'):
|
||||
return 'pkcs8'
|
||||
else:
|
||||
return 'pkcs1'
|
||||
|
||||
def _generate_private_key(self):
|
||||
"""(Re-)Generate private key."""
|
||||
try:
|
||||
if self.type == 'RSA':
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key(
|
||||
public_exponent=65537, # OpenSSL always uses this
|
||||
key_size=self.size,
|
||||
backend=self.cryptography_backend
|
||||
)
|
||||
if self.type == 'DSA':
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.dsa.generate_private_key(
|
||||
key_size=self.size,
|
||||
backend=self.cryptography_backend
|
||||
)
|
||||
if CRYPTOGRAPHY_HAS_X25519_FULL and self.type == 'X25519':
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.generate()
|
||||
if CRYPTOGRAPHY_HAS_X448 and self.type == 'X448':
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.generate()
|
||||
if CRYPTOGRAPHY_HAS_ED25519 and self.type == 'Ed25519':
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.generate()
|
||||
if CRYPTOGRAPHY_HAS_ED448 and self.type == 'Ed448':
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.generate()
|
||||
if self.type == 'ECC' and self.curve in self.curves:
|
||||
if self.curves[self.curve]['deprecated']:
|
||||
self.module.warn('Elliptic curves of type {0} should not be used for new keys!'.format(self.curve))
|
||||
self.privatekey = cryptography.hazmat.primitives.asymmetric.ec.generate_private_key(
|
||||
curve=self.curves[self.curve]['create'](self.size),
|
||||
backend=self.cryptography_backend
|
||||
)
|
||||
except cryptography.exceptions.UnsupportedAlgorithm as dummy:
|
||||
self.module.fail_json(msg='Cryptography backend does not support the algorithm required for {0}'.format(self.type))
|
||||
|
||||
def _ensure_private_key_loaded(self):
|
||||
"""Make sure that the private key has been loaded."""
|
||||
if self.privatekey is None:
|
||||
self.privatekey = self._load_privatekey()
|
||||
|
||||
def _get_private_key_data(self):
|
||||
"""Return bytes for self.privatekey"""
|
||||
# Select export format and encoding
|
||||
try:
|
||||
export_format = self._get_wanted_format()
|
||||
export_encoding = cryptography.hazmat.primitives.serialization.Encoding.PEM
|
||||
if export_format == 'pkcs1':
|
||||
# "TraditionalOpenSSL" format is PKCS1
|
||||
export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.TraditionalOpenSSL
|
||||
elif export_format == 'pkcs8':
|
||||
export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8
|
||||
elif export_format == 'raw':
|
||||
export_format = cryptography.hazmat.primitives.serialization.PrivateFormat.Raw
|
||||
export_encoding = cryptography.hazmat.primitives.serialization.Encoding.Raw
|
||||
except AttributeError:
|
||||
self.module.fail_json(msg='Cryptography backend does not support the selected output format "{0}"'.format(self.format))
|
||||
|
||||
# Select key encryption
|
||||
encryption_algorithm = cryptography.hazmat.primitives.serialization.NoEncryption()
|
||||
if self.cipher and self.passphrase:
|
||||
if self.cipher == 'auto':
|
||||
encryption_algorithm = cryptography.hazmat.primitives.serialization.BestAvailableEncryption(to_bytes(self.passphrase))
|
||||
else:
|
||||
self.module.fail_json(msg='Cryptography backend can only use "auto" for cipher option.')
|
||||
|
||||
# Serialize key
|
||||
try:
|
||||
return self.privatekey.private_bytes(
|
||||
encoding=export_encoding,
|
||||
format=export_format,
|
||||
encryption_algorithm=encryption_algorithm
|
||||
)
|
||||
except ValueError as dummy:
|
||||
self.module.fail_json(
|
||||
msg='Cryptography backend cannot serialize the private key in the required format "{0}"'.format(self.format)
|
||||
)
|
||||
except Exception as dummy:
|
||||
self.module.fail_json(
|
||||
msg='Error while serializing the private key in the required format "{0}"'.format(self.format),
|
||||
exception=traceback.format_exc()
|
||||
)
|
||||
|
||||
def _load_privatekey(self):
|
||||
try:
|
||||
# Read bytes
|
||||
with open(self.path, 'rb') as f:
|
||||
data = f.read()
|
||||
# Interpret bytes depending on format.
|
||||
format = crypto_utils.identify_private_key_format(data)
|
||||
if format == 'raw':
|
||||
if len(data) == 56 and CRYPTOGRAPHY_HAS_X448:
|
||||
return cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.from_private_bytes(data)
|
||||
if len(data) == 57 and CRYPTOGRAPHY_HAS_ED448:
|
||||
return cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey.from_private_bytes(data)
|
||||
if len(data) == 32:
|
||||
if CRYPTOGRAPHY_HAS_X25519 and (self.type == 'X25519' or not CRYPTOGRAPHY_HAS_ED25519):
|
||||
return cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data)
|
||||
if CRYPTOGRAPHY_HAS_ED25519 and (self.type == 'Ed25519' or not CRYPTOGRAPHY_HAS_X25519):
|
||||
return cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data)
|
||||
if CRYPTOGRAPHY_HAS_X25519 and CRYPTOGRAPHY_HAS_ED25519:
|
||||
try:
|
||||
return cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.from_private_bytes(data)
|
||||
except Exception:
|
||||
return cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(data)
|
||||
raise PrivateKeyError('Cannot load raw key')
|
||||
else:
|
||||
return cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
data,
|
||||
None if self.passphrase is None else to_bytes(self.passphrase),
|
||||
backend=self.cryptography_backend
|
||||
)
|
||||
except Exception as e:
|
||||
raise PrivateKeyError(e)
|
||||
|
||||
def _get_fingerprint(self):
|
||||
# Get bytes of public key
|
||||
private_key = self._load_privatekey()
|
||||
public_key = private_key.public_key()
|
||||
public_key_bytes = public_key.public_bytes(
|
||||
cryptography.hazmat.primitives.serialization.Encoding.DER,
|
||||
cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
# Get fingerprints of public_key_bytes
|
||||
return crypto_utils.get_fingerprint_of_bytes(public_key_bytes)
|
||||
|
||||
def _check_passphrase(self):
|
||||
try:
|
||||
with open(self.path, 'rb') as f:
|
||||
data = f.read()
|
||||
format = crypto_utils.identify_private_key_format(data)
|
||||
if format == 'raw':
|
||||
# Raw keys cannot be encrypted. To avoid incompatibilities, we try to
|
||||
# actually load the key (and return False when this fails).
|
||||
self._load_privatekey()
|
||||
# Loading the key succeeded. Only return True when no passphrase was
|
||||
# provided.
|
||||
return self.passphrase is None
|
||||
else:
|
||||
return cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
data,
|
||||
None if self.passphrase is None else to_bytes(self.passphrase),
|
||||
backend=self.cryptography_backend
|
||||
)
|
||||
except Exception as dummy:
|
||||
return False
|
||||
|
||||
def _check_size_and_type(self):
|
||||
self._ensure_private_key_loaded()
|
||||
|
||||
if isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
return self.type == 'RSA' and self.size == self.privatekey.key_size
|
||||
if isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
|
||||
return self.type == 'DSA' and self.size == self.privatekey.key_size
|
||||
if CRYPTOGRAPHY_HAS_X25519 and isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey):
|
||||
return self.type == 'X25519'
|
||||
if CRYPTOGRAPHY_HAS_X448 and isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey):
|
||||
return self.type == 'X448'
|
||||
if CRYPTOGRAPHY_HAS_ED25519 and isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
|
||||
return self.type == 'Ed25519'
|
||||
if CRYPTOGRAPHY_HAS_ED448 and isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
|
||||
return self.type == 'Ed448'
|
||||
if isinstance(self.privatekey, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
if self.type != 'ECC':
|
||||
return False
|
||||
if self.curve not in self.curves:
|
||||
return False
|
||||
return self.curves[self.curve]['verify'](self.privatekey)
|
||||
|
||||
return False
|
||||
|
||||
def _check_format(self):
|
||||
if self.format == 'auto_ignore':
|
||||
return True
|
||||
try:
|
||||
with open(self.path, 'rb') as f:
|
||||
content = f.read()
|
||||
format = crypto_utils.identify_private_key_format(content)
|
||||
return format == self._get_wanted_format()
|
||||
except Exception as dummy:
|
||||
return False
|
||||
|
||||
def dump(self):
|
||||
"""Serialize the object into a dictionary."""
|
||||
result = super(PrivateKeyCryptography, self).dump()
|
||||
result['type'] = self.type
|
||||
if self.type == 'ECC':
|
||||
result['curve'] = self.curve
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type='str', default='present', choices=['present', 'absent']),
|
||||
size=dict(type='int', default=4096),
|
||||
type=dict(type='str', default='RSA', choices=[
|
||||
'DSA', 'ECC', 'Ed25519', 'Ed448', 'RSA', 'X25519', 'X448'
|
||||
]),
|
||||
curve=dict(type='str', choices=[
|
||||
'secp384r1', 'secp521r1', 'secp224r1', 'secp192r1', 'secp256r1',
|
||||
'secp256k1', 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1',
|
||||
'sect571k1', 'sect409k1', 'sect283k1', 'sect233k1', 'sect163k1',
|
||||
'sect571r1', 'sect409r1', 'sect283r1', 'sect233r1', 'sect163r2',
|
||||
]),
|
||||
force=dict(type='bool', default=False),
|
||||
path=dict(type='path', required=True),
|
||||
passphrase=dict(type='str', no_log=True),
|
||||
cipher=dict(type='str'),
|
||||
backup=dict(type='bool', default=False),
|
||||
format=dict(type='str', default='auto_ignore', choices=['pkcs1', 'pkcs8', 'raw', 'auto', 'auto_ignore']),
|
||||
format_mismatch=dict(type='str', default='regenerate', choices=['regenerate', 'convert']),
|
||||
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
|
||||
return_content=dict(type='bool', default=False),
|
||||
regenerate=dict(
|
||||
type='str',
|
||||
default='full_idempotence',
|
||||
choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always']
|
||||
),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
required_together=[
|
||||
['cipher', 'passphrase']
|
||||
],
|
||||
required_if=[
|
||||
['type', 'ECC', ['curve']],
|
||||
],
|
||||
)
|
||||
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
)
|
||||
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# Decision
|
||||
if module.params['cipher'] and module.params['passphrase'] and module.params['cipher'] != 'auto':
|
||||
# First try pyOpenSSL, then cryptography
|
||||
if can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
elif can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
else:
|
||||
# First try cryptography, then pyOpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
try:
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', version='2.13')
|
||||
private_key = PrivateKeyPyOpenSSL(module)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
private_key = PrivateKeyCryptography(module)
|
||||
|
||||
if private_key.state == 'present':
|
||||
if module.check_mode:
|
||||
result = private_key.dump()
|
||||
result['changed'] = private_key.force \
|
||||
or not private_key.check(module, ignore_conversion=True) \
|
||||
or not private_key.check(module, ignore_conversion=False)
|
||||
module.exit_json(**result)
|
||||
|
||||
private_key.generate(module)
|
||||
else:
|
||||
if module.check_mode:
|
||||
result = private_key.dump()
|
||||
result['changed'] = os.path.exists(module.params['path'])
|
||||
module.exit_json(**result)
|
||||
|
||||
private_key.remove(module)
|
||||
|
||||
result = private_key.dump()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
649
plugins/modules/openssl_privatekey_info.py
Normal file
649
plugins/modules/openssl_privatekey_info.py
Normal file
@@ -0,0 +1,649 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_privatekey_info
|
||||
short_description: Provide information for OpenSSL private keys
|
||||
description:
|
||||
- This module allows one to query information on OpenSSL private keys.
|
||||
- In case the key consistency checks fail, the module will fail as this indicates a faked
|
||||
private key. In this case, all return variables are still returned. Note that key consistency
|
||||
checks are not available all key types; if none is available, C(none) is returned for
|
||||
C(key_is_consistent).
|
||||
- It uses the pyOpenSSL or cryptography python library to interact with OpenSSL. If both the
|
||||
cryptography and PyOpenSSL libraries are available (and meet the minimum version requirements)
|
||||
cryptography will be preferred as a backend over PyOpenSSL (unless the backend is forced with
|
||||
C(select_crypto_backend)). Please note that the PyOpenSSL backend was deprecated in Ansible 2.9
|
||||
and will be removed in Ansible 2.13.
|
||||
requirements:
|
||||
- PyOpenSSL >= 0.15 or cryptography >= 1.2.3
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
- Yanis Guenane (@Spredzy)
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- Remote absolute path where the private key file is loaded from.
|
||||
type: path
|
||||
content:
|
||||
description:
|
||||
- Content of the private key file.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: str
|
||||
passphrase:
|
||||
description:
|
||||
- The passphrase for the private key.
|
||||
type: str
|
||||
return_private_key_data:
|
||||
description:
|
||||
- Whether to return private key data.
|
||||
- Only set this to C(yes) when you want private information about this key to
|
||||
leave the remote machine.
|
||||
- "WARNING: you have to make sure that private key data isn't accidentally logged!"
|
||||
type: bool
|
||||
default: no
|
||||
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
- Please note that the C(pyopenssl) backend has been deprecated in Ansible 2.9, and will be removed in Ansible 2.13.
|
||||
From that point on, only the C(cryptography) backend will be available.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
|
||||
seealso:
|
||||
- module: openssl_privatekey
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate an OpenSSL private key with the default values (4096 bits, RSA)
|
||||
openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
|
||||
- name: Get information on generated key
|
||||
openssl_privatekey_info:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
register: result
|
||||
|
||||
- name: Dump information
|
||||
debug:
|
||||
var: result
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
can_load_key:
|
||||
description: Whether the module was able to load the private key from disk
|
||||
returned: always
|
||||
type: bool
|
||||
can_parse_key:
|
||||
description: Whether the module was able to parse the private key
|
||||
returned: always
|
||||
type: bool
|
||||
key_is_consistent:
|
||||
description:
|
||||
- Whether the key is consistent. Can also return C(none) next to C(yes) and
|
||||
C(no), to indicate that consistency couldn't be checked.
|
||||
- In case the check returns C(no), the module will fail.
|
||||
returned: always
|
||||
type: bool
|
||||
public_key:
|
||||
description: Private key's public key in PEM format
|
||||
returned: success
|
||||
type: str
|
||||
sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..."
|
||||
public_key_fingerprints:
|
||||
description:
|
||||
- Fingerprints of private key's public key.
|
||||
- For every hash algorithm available, the fingerprint is computed.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63',
|
||||
'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..."
|
||||
type:
|
||||
description:
|
||||
- The key's type.
|
||||
- One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448).
|
||||
- Will start with C(unknown) if the key type cannot be determined.
|
||||
returned: success
|
||||
type: str
|
||||
sample: RSA
|
||||
public_data:
|
||||
description:
|
||||
- Public key data. Depends on key type.
|
||||
returned: success
|
||||
type: dict
|
||||
private_data:
|
||||
description:
|
||||
- Private key data. Depends on key type.
|
||||
returned: success and when I(return_private_key_data) is set to C(yes)
|
||||
type: dict
|
||||
'''
|
||||
|
||||
|
||||
import abc
|
||||
import os
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils._text import to_native, to_bytes
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
try:
|
||||
import cryptography.hazmat.primitives.asymmetric.x25519
|
||||
CRYPTOGRAPHY_HAS_X25519 = True
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_HAS_X25519 = False
|
||||
try:
|
||||
import cryptography.hazmat.primitives.asymmetric.x448
|
||||
CRYPTOGRAPHY_HAS_X448 = True
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_HAS_X448 = False
|
||||
try:
|
||||
import cryptography.hazmat.primitives.asymmetric.ed25519
|
||||
CRYPTOGRAPHY_HAS_ED25519 = True
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_HAS_ED25519 = False
|
||||
try:
|
||||
import cryptography.hazmat.primitives.asymmetric.ed448
|
||||
CRYPTOGRAPHY_HAS_ED448 = True
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_HAS_ED448 = False
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
SIGNATURE_TEST_DATA = b'1234'
|
||||
|
||||
|
||||
def _get_cryptography_key_info(key):
|
||||
key_public_data = dict()
|
||||
key_private_data = dict()
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
key_type = 'RSA'
|
||||
key_public_data['size'] = key.key_size
|
||||
key_public_data['modulus'] = key.public_key().public_numbers().n
|
||||
key_public_data['exponent'] = key.public_key().public_numbers().e
|
||||
key_private_data['p'] = key.private_numbers().p
|
||||
key_private_data['q'] = key.private_numbers().q
|
||||
key_private_data['exponent'] = key.private_numbers().d
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
|
||||
key_type = 'DSA'
|
||||
key_public_data['size'] = key.key_size
|
||||
key_public_data['p'] = key.parameters().parameter_numbers().p
|
||||
key_public_data['q'] = key.parameters().parameter_numbers().q
|
||||
key_public_data['g'] = key.parameters().parameter_numbers().g
|
||||
key_public_data['y'] = key.public_key().public_numbers().y
|
||||
key_private_data['x'] = key.private_numbers().x
|
||||
elif CRYPTOGRAPHY_HAS_X25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey):
|
||||
key_type = 'X25519'
|
||||
elif CRYPTOGRAPHY_HAS_X448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey):
|
||||
key_type = 'X448'
|
||||
elif CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
|
||||
key_type = 'Ed25519'
|
||||
elif CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
|
||||
key_type = 'Ed448'
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
key_type = 'ECC'
|
||||
key_public_data['curve'] = key.public_key().curve.name
|
||||
key_public_data['x'] = key.public_key().public_numbers().x
|
||||
key_public_data['y'] = key.public_key().public_numbers().y
|
||||
key_public_data['exponent_size'] = key.public_key().curve.key_size
|
||||
key_private_data['multiplier'] = key.private_numbers().private_value
|
||||
else:
|
||||
key_type = 'unknown ({0})'.format(type(key))
|
||||
return key_type, key_public_data, key_private_data
|
||||
|
||||
|
||||
def _check_dsa_consistency(key_public_data, key_private_data):
|
||||
# Get parameters
|
||||
p = key_public_data.get('p')
|
||||
q = key_public_data.get('q')
|
||||
g = key_public_data.get('g')
|
||||
y = key_public_data.get('y')
|
||||
x = key_private_data.get('x')
|
||||
for v in (p, q, g, y, x):
|
||||
if v is None:
|
||||
return None
|
||||
# Make sure that g is not 0, 1 or -1 in Z/pZ
|
||||
if g < 2 or g >= p - 1:
|
||||
return False
|
||||
# Make sure that x is in range
|
||||
if x < 1 or x >= q:
|
||||
return False
|
||||
# Check whether q divides p-1
|
||||
if (p - 1) % q != 0:
|
||||
return False
|
||||
# Check that g**q mod p == 1
|
||||
if crypto_utils.binary_exp_mod(g, q, p) != 1:
|
||||
return False
|
||||
# Check whether g**x mod p == y
|
||||
if crypto_utils.binary_exp_mod(g, x, p) != y:
|
||||
return False
|
||||
# Check (quickly) whether p or q are not primes
|
||||
if crypto_utils.quick_is_not_prime(q) or crypto_utils.quick_is_not_prime(p):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_cryptography_key_consistent(key, key_public_data, key_private_data):
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
return bool(key._backend._lib.RSA_check_key(key._rsa_cdata))
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
|
||||
result = _check_dsa_consistency(key_public_data, key_private_data)
|
||||
if result is not None:
|
||||
return result
|
||||
try:
|
||||
signature = key.sign(SIGNATURE_TEST_DATA, cryptography.hazmat.primitives.hashes.SHA256())
|
||||
except AttributeError:
|
||||
# sign() was added in cryptography 1.5, but we support older versions
|
||||
return None
|
||||
try:
|
||||
key.public_key().verify(
|
||||
signature,
|
||||
SIGNATURE_TEST_DATA,
|
||||
cryptography.hazmat.primitives.hashes.SHA256()
|
||||
)
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
return False
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
try:
|
||||
signature = key.sign(
|
||||
SIGNATURE_TEST_DATA,
|
||||
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256())
|
||||
)
|
||||
except AttributeError:
|
||||
# sign() was added in cryptography 1.5, but we support older versions
|
||||
return None
|
||||
try:
|
||||
key.public_key().verify(
|
||||
signature,
|
||||
SIGNATURE_TEST_DATA,
|
||||
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256())
|
||||
)
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
return False
|
||||
has_simple_sign_function = False
|
||||
if CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
|
||||
has_simple_sign_function = True
|
||||
if CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
|
||||
has_simple_sign_function = True
|
||||
if has_simple_sign_function:
|
||||
signature = key.sign(SIGNATURE_TEST_DATA)
|
||||
try:
|
||||
key.public_key().verify(signature, SIGNATURE_TEST_DATA)
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
return False
|
||||
# For X25519 and X448, there's no test yet.
|
||||
return None
|
||||
|
||||
|
||||
class PrivateKeyInfo(crypto_utils.OpenSSLObject):
|
||||
def __init__(self, module, backend):
|
||||
super(PrivateKeyInfo, self).__init__(
|
||||
module.params['path'] or '',
|
||||
'present',
|
||||
False,
|
||||
module.check_mode,
|
||||
)
|
||||
self.backend = backend
|
||||
self.module = module
|
||||
self.content = module.params['content']
|
||||
|
||||
self.passphrase = module.params['passphrase']
|
||||
self.return_private_key_data = module.params['return_private_key_data']
|
||||
|
||||
def generate(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
def dump(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key(self, binary):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_info(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_key_consistent(self, key_public_data, key_private_data):
|
||||
pass
|
||||
|
||||
def get_info(self):
|
||||
result = dict(
|
||||
can_load_key=False,
|
||||
can_parse_key=False,
|
||||
key_is_consistent=None,
|
||||
)
|
||||
if self.content is not None:
|
||||
priv_key_detail = self.content.encode('utf-8')
|
||||
result['can_load_key'] = True
|
||||
else:
|
||||
try:
|
||||
with open(self.path, 'rb') as b_priv_key_fh:
|
||||
priv_key_detail = b_priv_key_fh.read()
|
||||
result['can_load_key'] = True
|
||||
except (IOError, OSError) as exc:
|
||||
self.module.fail_json(msg=to_native(exc), **result)
|
||||
try:
|
||||
self.key = crypto_utils.load_privatekey(
|
||||
path=None,
|
||||
content=priv_key_detail,
|
||||
passphrase=to_bytes(self.passphrase) if self.passphrase is not None else self.passphrase,
|
||||
backend=self.backend
|
||||
)
|
||||
result['can_parse_key'] = True
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
self.module.fail_json(msg=to_native(exc), **result)
|
||||
|
||||
result['public_key'] = self._get_public_key(binary=False)
|
||||
pk = self._get_public_key(binary=True)
|
||||
result['public_key_fingerprints'] = crypto_utils.get_fingerprint_of_bytes(pk) if pk is not None else dict()
|
||||
|
||||
key_type, key_public_data, key_private_data = self._get_key_info()
|
||||
result['type'] = key_type
|
||||
result['public_data'] = key_public_data
|
||||
if self.return_private_key_data:
|
||||
result['private_data'] = key_private_data
|
||||
|
||||
result['key_is_consistent'] = self._is_key_consistent(key_public_data, key_private_data)
|
||||
if result['key_is_consistent'] is False:
|
||||
# Only fail when it is False, to avoid to fail on None (which means "we don't know")
|
||||
result['key_is_consistent'] = False
|
||||
self.module.fail_json(
|
||||
msg="Private key is not consistent! (See "
|
||||
"https://blog.hboeck.de/archives/888-How-I-tricked-Symantec-with-a-Fake-Private-Key.html)",
|
||||
**result
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class PrivateKeyInfoCryptography(PrivateKeyInfo):
|
||||
"""Validate the supplied private key, using the cryptography backend"""
|
||||
def __init__(self, module):
|
||||
super(PrivateKeyInfoCryptography, self).__init__(module, 'cryptography')
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
return self.key.public_key().public_bytes(
|
||||
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
def _get_key_info(self):
|
||||
return _get_cryptography_key_info(self.key)
|
||||
|
||||
def _is_key_consistent(self, key_public_data, key_private_data):
|
||||
return _is_cryptography_key_consistent(self.key, key_public_data, key_private_data)
|
||||
|
||||
|
||||
class PrivateKeyInfoPyOpenSSL(PrivateKeyInfo):
|
||||
"""validate the supplied private key."""
|
||||
|
||||
def __init__(self, module):
|
||||
super(PrivateKeyInfoPyOpenSSL, self).__init__(module, 'pyopenssl')
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
|
||||
self.key
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
# pyOpenSSL < 16.0:
|
||||
bio = crypto._new_mem_buf()
|
||||
if binary:
|
||||
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.key._pkey)
|
||||
else:
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.key._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def bigint_to_int(self, bn):
|
||||
'''Convert OpenSSL BIGINT to Python integer'''
|
||||
if bn == OpenSSL._util.ffi.NULL:
|
||||
return None
|
||||
hexstr = OpenSSL._util.lib.BN_bn2hex(bn)
|
||||
try:
|
||||
return int(OpenSSL._util.ffi.string(hexstr), 16)
|
||||
finally:
|
||||
OpenSSL._util.lib.OPENSSL_free(hexstr)
|
||||
|
||||
def _get_key_info(self):
|
||||
key_public_data = dict()
|
||||
key_private_data = dict()
|
||||
openssl_key_type = self.key.type()
|
||||
try_fallback = True
|
||||
if crypto.TYPE_RSA == openssl_key_type:
|
||||
key_type = 'RSA'
|
||||
key_public_data['size'] = self.key.bits()
|
||||
|
||||
try:
|
||||
# Use OpenSSL directly to extract key data
|
||||
key = OpenSSL._util.lib.EVP_PKEY_get1_RSA(self.key._pkey)
|
||||
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.RSA_free)
|
||||
# OpenSSL 1.1 and newer have functions to extract the parameters
|
||||
# from the EVP PKEY data structures. Older versions didn't have
|
||||
# these getters, and it was common use to simply access the values
|
||||
# directly. Since there's no guarantee that these data structures
|
||||
# will still be accessible in the future, we use the getters for
|
||||
# 1.1 and later, and directly access the values for 1.0.x and
|
||||
# earlier.
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# Get modulus and exponents
|
||||
n = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
e = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
d = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.RSA_get0_key(key, n, e, d)
|
||||
key_public_data['modulus'] = self.bigint_to_int(n[0])
|
||||
key_public_data['exponent'] = self.bigint_to_int(e[0])
|
||||
key_private_data['exponent'] = self.bigint_to_int(d[0])
|
||||
# Get factors
|
||||
p = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
q = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.RSA_get0_factors(key, p, q)
|
||||
key_private_data['p'] = self.bigint_to_int(p[0])
|
||||
key_private_data['q'] = self.bigint_to_int(q[0])
|
||||
else:
|
||||
# Get modulus and exponents
|
||||
key_public_data['modulus'] = self.bigint_to_int(key.n)
|
||||
key_public_data['exponent'] = self.bigint_to_int(key.e)
|
||||
key_private_data['exponent'] = self.bigint_to_int(key.d)
|
||||
# Get factors
|
||||
key_private_data['p'] = self.bigint_to_int(key.p)
|
||||
key_private_data['q'] = self.bigint_to_int(key.q)
|
||||
try_fallback = False
|
||||
except AttributeError:
|
||||
# Use fallback if available
|
||||
pass
|
||||
elif crypto.TYPE_DSA == openssl_key_type:
|
||||
key_type = 'DSA'
|
||||
key_public_data['size'] = self.key.bits()
|
||||
|
||||
try:
|
||||
# Use OpenSSL directly to extract key data
|
||||
key = OpenSSL._util.lib.EVP_PKEY_get1_DSA(self.key._pkey)
|
||||
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.DSA_free)
|
||||
# OpenSSL 1.1 and newer have functions to extract the parameters
|
||||
# from the EVP PKEY data structures. Older versions didn't have
|
||||
# these getters, and it was common use to simply access the values
|
||||
# directly. Since there's no guarantee that these data structures
|
||||
# will still be accessible in the future, we use the getters for
|
||||
# 1.1 and later, and directly access the values for 1.0.x and
|
||||
# earlier.
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# Get public parameters (primes and group element)
|
||||
p = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
q = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
g = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.DSA_get0_pqg(key, p, q, g)
|
||||
key_public_data['p'] = self.bigint_to_int(p[0])
|
||||
key_public_data['q'] = self.bigint_to_int(q[0])
|
||||
key_public_data['g'] = self.bigint_to_int(g[0])
|
||||
# Get public and private key exponents
|
||||
y = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
x = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.DSA_get0_key(key, y, x)
|
||||
key_public_data['y'] = self.bigint_to_int(y[0])
|
||||
key_private_data['x'] = self.bigint_to_int(x[0])
|
||||
else:
|
||||
# Get public parameters (primes and group element)
|
||||
key_public_data['p'] = self.bigint_to_int(key.p)
|
||||
key_public_data['q'] = self.bigint_to_int(key.q)
|
||||
key_public_data['g'] = self.bigint_to_int(key.g)
|
||||
# Get public and private key exponents
|
||||
key_public_data['y'] = self.bigint_to_int(key.pub_key)
|
||||
key_private_data['x'] = self.bigint_to_int(key.priv_key)
|
||||
try_fallback = False
|
||||
except AttributeError:
|
||||
# Use fallback if available
|
||||
pass
|
||||
else:
|
||||
# Return 'unknown'
|
||||
key_type = 'unknown ({0})'.format(self.key.type())
|
||||
# If needed and if possible, fall back to cryptography
|
||||
if try_fallback and PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
|
||||
return _get_cryptography_key_info(self.key.to_cryptography_key())
|
||||
return key_type, key_public_data, key_private_data
|
||||
|
||||
def _is_key_consistent(self, key_public_data, key_private_data):
|
||||
openssl_key_type = self.key.type()
|
||||
if crypto.TYPE_RSA == openssl_key_type:
|
||||
try:
|
||||
return self.key.check()
|
||||
except crypto.Error:
|
||||
# OpenSSL error means that key is not consistent
|
||||
return False
|
||||
if crypto.TYPE_DSA == openssl_key_type:
|
||||
result = _check_dsa_consistency(key_public_data, key_private_data)
|
||||
if result is not None:
|
||||
return result
|
||||
signature = crypto.sign(self.key, SIGNATURE_TEST_DATA, 'sha256')
|
||||
# Verify wants a cert (where it can get the public key from)
|
||||
cert = crypto.X509()
|
||||
cert.set_pubkey(self.key)
|
||||
try:
|
||||
crypto.verify(cert, signature, SIGNATURE_TEST_DATA, 'sha256')
|
||||
return True
|
||||
except crypto.Error:
|
||||
return False
|
||||
# If needed and if possible, fall back to cryptography
|
||||
if PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
|
||||
return _is_cryptography_key_consistent(self.key.to_cryptography_key(), key_public_data, key_private_data)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str'),
|
||||
passphrase=dict(type='str', no_log=True),
|
||||
return_private_key_data=dict(type='bool', default=False),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
|
||||
),
|
||||
required_one_of=(
|
||||
['path', 'content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['path', 'content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
try:
|
||||
if module.params['path'] is not None:
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
)
|
||||
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
# Detect what backend we can use
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# If cryptography is available we'll use it
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Fail if no backend has been found
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', version='2.13')
|
||||
privatekey = PrivateKeyInfoPyOpenSSL(module)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
privatekey = PrivateKeyInfoCryptography(module)
|
||||
|
||||
result = privatekey.get_info()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
471
plugins/modules/openssl_publickey.py
Normal file
471
plugins/modules/openssl_publickey.py
Normal file
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_publickey
|
||||
short_description: Generate an OpenSSL public key from its private key.
|
||||
description:
|
||||
- This module allows one to (re)generate OpenSSL public keys from their private keys.
|
||||
- Keys are generated in PEM or OpenSSH format.
|
||||
- The module can use the cryptography Python library, or the pyOpenSSL Python
|
||||
library. By default, it tries to detect which one is available. This can be
|
||||
overridden with the I(select_crypto_backend) option. When I(format) is C(OpenSSH),
|
||||
the C(cryptography) backend has to be used. Please note that the PyOpenSSL backend
|
||||
was deprecated in Ansible 2.9 and will be removed in Ansible 2.13."
|
||||
requirements:
|
||||
- Either cryptography >= 1.2.3 (older versions might work as well)
|
||||
- Or pyOpenSSL >= 16.0.0
|
||||
- Needs cryptography >= 1.4 if I(format) is C(OpenSSH)
|
||||
author:
|
||||
- Yanis Guenane (@Spredzy)
|
||||
- Felix Fontein (@felixfontein)
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the public key should exist or not, taking action if the state is different from what is stated.
|
||||
type: str
|
||||
default: present
|
||||
choices: [ absent, present ]
|
||||
force:
|
||||
description:
|
||||
- Should the key be regenerated even it it already exists.
|
||||
type: bool
|
||||
default: no
|
||||
format:
|
||||
description:
|
||||
- The format of the public key.
|
||||
type: str
|
||||
default: PEM
|
||||
choices: [ OpenSSH, PEM ]
|
||||
path:
|
||||
description:
|
||||
- Name of the file in which the generated TLS/SSL public key will be written.
|
||||
type: path
|
||||
required: true
|
||||
privatekey_path:
|
||||
description:
|
||||
- Path to the TLS/SSL private key from which to generate the public key.
|
||||
- Either I(privatekey_path) or I(privatekey_content) must be specified, but not both.
|
||||
If I(state) is C(present), one of them is required.
|
||||
type: path
|
||||
privatekey_content:
|
||||
description:
|
||||
- The content of the TLS/SSL private key from which to generate the public key.
|
||||
- Either I(privatekey_path) or I(privatekey_content) must be specified, but not both.
|
||||
If I(state) is C(present), one of them is required.
|
||||
type: str
|
||||
privatekey_passphrase:
|
||||
description:
|
||||
- The passphrase for the private key.
|
||||
type: str
|
||||
backup:
|
||||
description:
|
||||
- Create a backup file including a timestamp so you can get the original
|
||||
public key back if you overwrote it with a different one by accident.
|
||||
type: bool
|
||||
default: no
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
return_content:
|
||||
description:
|
||||
- If set to C(yes), will return the (current or generated) public key's content as I(publickey).
|
||||
type: bool
|
||||
default: no
|
||||
extends_documentation_fragment:
|
||||
- files
|
||||
seealso:
|
||||
- module: openssl_certificate
|
||||
- module: openssl_csr
|
||||
- module: openssl_dhparam
|
||||
- module: openssl_pkcs12
|
||||
- module: openssl_privatekey
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate an OpenSSL public key in PEM format
|
||||
openssl_publickey:
|
||||
path: /etc/ssl/public/ansible.com.pem
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
|
||||
- name: Generate an OpenSSL public key in PEM format from an inline key
|
||||
openssl_publickey:
|
||||
path: /etc/ssl/public/ansible.com.pem
|
||||
privatekey_content: "{{ private_key_content }}"
|
||||
|
||||
- name: Generate an OpenSSL public key in OpenSSH v2 format
|
||||
openssl_publickey:
|
||||
path: /etc/ssl/public/ansible.com.pem
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
format: OpenSSH
|
||||
|
||||
- name: Generate an OpenSSL public key with a passphrase protected private key
|
||||
openssl_publickey:
|
||||
path: /etc/ssl/public/ansible.com.pem
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
privatekey_passphrase: ansible
|
||||
|
||||
- name: Force regenerate an OpenSSL public key if it already exists
|
||||
openssl_publickey:
|
||||
path: /etc/ssl/public/ansible.com.pem
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
force: yes
|
||||
|
||||
- name: Remove an OpenSSL public key
|
||||
openssl_publickey:
|
||||
path: /etc/ssl/public/ansible.com.pem
|
||||
state: absent
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
privatekey:
|
||||
description:
|
||||
- Path to the TLS/SSL private key the public key was generated from.
|
||||
- Will be C(none) if the private key has been provided in I(privatekey_content).
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /etc/ssl/private/ansible.com.pem
|
||||
format:
|
||||
description: The format of the public key (PEM, OpenSSH, ...).
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: PEM
|
||||
filename:
|
||||
description: Path to the generated TLS/SSL public key file.
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /etc/ssl/public/ansible.com.pem
|
||||
fingerprint:
|
||||
description:
|
||||
- The fingerprint of the public key. Fingerprint will be generated for each hashlib.algorithms available.
|
||||
- Requires PyOpenSSL >= 16.0 for meaningful output.
|
||||
returned: changed or success
|
||||
type: dict
|
||||
sample:
|
||||
md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29"
|
||||
sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10"
|
||||
sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46"
|
||||
sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7"
|
||||
sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d"
|
||||
sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b"
|
||||
backup_file:
|
||||
description: Name of backup file created.
|
||||
returned: changed and if I(backup) is C(yes)
|
||||
type: str
|
||||
sample: /path/to/publickey.pem.2019-03-09@11:22~
|
||||
publickey:
|
||||
description: The (current or generated) public key's content.
|
||||
returned: if I(state) is C(present) and I(return_content) is C(yes)
|
||||
type: str
|
||||
version_added: "2.10"
|
||||
'''
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
MINIMAL_PYOPENSSL_VERSION = '16.0.0'
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH = '1.4'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
|
||||
|
||||
class PublicKeyError(crypto_utils.OpenSSLObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class PublicKey(crypto_utils.OpenSSLObject):
|
||||
|
||||
def __init__(self, module, backend):
|
||||
super(PublicKey, self).__init__(
|
||||
module.params['path'],
|
||||
module.params['state'],
|
||||
module.params['force'],
|
||||
module.check_mode
|
||||
)
|
||||
self.format = module.params['format']
|
||||
self.privatekey_path = module.params['privatekey_path']
|
||||
self.privatekey_content = module.params['privatekey_content']
|
||||
if self.privatekey_content is not None:
|
||||
self.privatekey_content = self.privatekey_content.encode('utf-8')
|
||||
self.privatekey_passphrase = module.params['privatekey_passphrase']
|
||||
self.privatekey = None
|
||||
self.publickey_bytes = None
|
||||
self.return_content = module.params['return_content']
|
||||
self.fingerprint = {}
|
||||
self.backend = backend
|
||||
|
||||
self.backup = module.params['backup']
|
||||
self.backup_file = None
|
||||
|
||||
def _create_publickey(self, module):
|
||||
self.privatekey = crypto_utils.load_privatekey(
|
||||
path=self.privatekey_path,
|
||||
content=self.privatekey_content,
|
||||
passphrase=self.privatekey_passphrase,
|
||||
backend=self.backend
|
||||
)
|
||||
if self.backend == 'cryptography':
|
||||
if self.format == 'OpenSSH':
|
||||
return self.privatekey.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.OpenSSH,
|
||||
crypto_serialization.PublicFormat.OpenSSH
|
||||
)
|
||||
else:
|
||||
return self.privatekey.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
else:
|
||||
try:
|
||||
return crypto.dump_publickey(crypto.FILETYPE_PEM, self.privatekey)
|
||||
except AttributeError as dummy:
|
||||
raise PublicKeyError('You need to have PyOpenSSL>=16.0.0 to generate public keys')
|
||||
|
||||
def generate(self, module):
|
||||
"""Generate the public key."""
|
||||
|
||||
if self.privatekey_content is None and not os.path.exists(self.privatekey_path):
|
||||
raise PublicKeyError(
|
||||
'The private key %s does not exist' % self.privatekey_path
|
||||
)
|
||||
|
||||
if not self.check(module, perms_required=False) or self.force:
|
||||
try:
|
||||
publickey_content = self._create_publickey(module)
|
||||
if self.return_content:
|
||||
self.publickey_bytes = publickey_content
|
||||
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
crypto_utils.write_file(module, publickey_content)
|
||||
|
||||
self.changed = True
|
||||
except crypto_utils.OpenSSLBadPassphraseError as exc:
|
||||
raise PublicKeyError(exc)
|
||||
except (IOError, OSError) as exc:
|
||||
raise PublicKeyError(exc)
|
||||
|
||||
self.fingerprint = crypto_utils.get_fingerprint(
|
||||
path=self.privatekey_path,
|
||||
content=self.privatekey_content,
|
||||
passphrase=self.privatekey_passphrase,
|
||||
backend=self.backend,
|
||||
)
|
||||
file_args = module.load_file_common_arguments(module.params)
|
||||
if module.set_fs_attributes_if_different(file_args, False):
|
||||
self.changed = True
|
||||
|
||||
def check(self, module, perms_required=True):
|
||||
"""Ensure the resource is in its desired state."""
|
||||
|
||||
state_and_perms = super(PublicKey, self).check(module, perms_required)
|
||||
|
||||
def _check_privatekey():
|
||||
if self.privatekey_content is None and not os.path.exists(self.privatekey_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(self.path, 'rb') as public_key_fh:
|
||||
publickey_content = public_key_fh.read()
|
||||
if self.return_content:
|
||||
self.publickey_bytes = publickey_content
|
||||
if self.backend == 'cryptography':
|
||||
if self.format == 'OpenSSH':
|
||||
# Read and dump public key. Makes sure that the comment is stripped off.
|
||||
current_publickey = crypto_serialization.load_ssh_public_key(publickey_content, backend=default_backend())
|
||||
publickey_content = current_publickey.public_bytes(
|
||||
crypto_serialization.Encoding.OpenSSH,
|
||||
crypto_serialization.PublicFormat.OpenSSH
|
||||
)
|
||||
else:
|
||||
current_publickey = crypto_serialization.load_pem_public_key(publickey_content, backend=default_backend())
|
||||
publickey_content = current_publickey.public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
else:
|
||||
publickey_content = crypto.dump_publickey(
|
||||
crypto.FILETYPE_PEM,
|
||||
crypto.load_publickey(crypto.FILETYPE_PEM, publickey_content)
|
||||
)
|
||||
except Exception as dummy:
|
||||
return False
|
||||
|
||||
try:
|
||||
desired_publickey = self._create_publickey(module)
|
||||
except crypto_utils.OpenSSLBadPassphraseError as exc:
|
||||
raise PublicKeyError(exc)
|
||||
|
||||
return publickey_content == desired_publickey
|
||||
|
||||
if not state_and_perms:
|
||||
return state_and_perms
|
||||
|
||||
return _check_privatekey()
|
||||
|
||||
def remove(self, module):
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
super(PublicKey, self).remove(module)
|
||||
|
||||
def dump(self):
|
||||
"""Serialize the object into a dictionary."""
|
||||
|
||||
result = {
|
||||
'privatekey': self.privatekey_path,
|
||||
'filename': self.path,
|
||||
'format': self.format,
|
||||
'changed': self.changed,
|
||||
'fingerprint': self.fingerprint,
|
||||
}
|
||||
if self.backup_file:
|
||||
result['backup_file'] = self.backup_file
|
||||
if self.return_content:
|
||||
if self.publickey_bytes is None:
|
||||
self.publickey_bytes = crypto_utils.load_file_if_exists(self.path, ignore_errors=True)
|
||||
result['publickey'] = self.publickey_bytes.decode('utf-8') if self.publickey_bytes else None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type='str', default='present', choices=['present', 'absent']),
|
||||
force=dict(type='bool', default=False),
|
||||
path=dict(type='path', required=True),
|
||||
privatekey_path=dict(type='path'),
|
||||
privatekey_content=dict(type='str'),
|
||||
format=dict(type='str', default='PEM', choices=['OpenSSH', 'PEM']),
|
||||
privatekey_passphrase=dict(type='str', no_log=True),
|
||||
backup=dict(type='bool', default=False),
|
||||
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
|
||||
return_content=dict(type='bool', default=False),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
required_if=[('state', 'present', ['privatekey_path', 'privatekey_content'], True)],
|
||||
mutually_exclusive=(
|
||||
['privatekey_path', 'privatekey_content'],
|
||||
),
|
||||
)
|
||||
|
||||
minimal_cryptography_version = MINIMAL_CRYPTOGRAPHY_VERSION
|
||||
if module.params['format'] == 'OpenSSH':
|
||||
minimal_cryptography_version = MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH
|
||||
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(minimal_cryptography_version)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# Decision
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
if module.params['format'] == 'OpenSSH':
|
||||
module.fail_json(
|
||||
msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR
|
||||
)
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
minimal_cryptography_version,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if module.params['format'] == 'OpenSSH' and backend != 'cryptography':
|
||||
module.fail_json(msg="Format OpenSSH requires the cryptography backend.")
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated', version='2.13')
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(minimal_cryptography_version)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
module.fail_json(
|
||||
name=base_dir,
|
||||
msg="The directory '%s' does not exist or the file is not a directory" % base_dir
|
||||
)
|
||||
|
||||
try:
|
||||
public_key = PublicKey(module, backend)
|
||||
|
||||
if public_key.state == 'present':
|
||||
if module.check_mode:
|
||||
result = public_key.dump()
|
||||
result['changed'] = module.params['force'] or not public_key.check(module)
|
||||
module.exit_json(**result)
|
||||
|
||||
public_key.generate(module)
|
||||
else:
|
||||
if module.check_mode:
|
||||
result = public_key.dump()
|
||||
result['changed'] = os.path.exists(module.params['path'])
|
||||
module.exit_json(**result)
|
||||
|
||||
public_key.remove(module)
|
||||
|
||||
result = public_key.dump()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
782
plugins/modules/x509_crl.py
Normal file
782
plugins/modules/x509_crl.py
Normal file
@@ -0,0 +1,782 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2019, 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
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: x509_crl
|
||||
short_description: Generate Certificate Revocation Lists (CRLs)
|
||||
description:
|
||||
- This module allows one to (re)generate or update Certificate Revocation Lists (CRLs).
|
||||
- Certificates on the revocation list can be either specified via serial number and (optionally) their issuer,
|
||||
or as a path to a certificate file in PEM format.
|
||||
requirements:
|
||||
- cryptography >= 1.2
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether the CRL file should exist or not, taking action if the state is different from what is stated.
|
||||
type: str
|
||||
default: present
|
||||
choices: [ absent, present ]
|
||||
|
||||
mode:
|
||||
description:
|
||||
- Defines how to process entries of existing CRLs.
|
||||
- If set to C(generate), makes sure that the CRL has the exact set of revoked certificates
|
||||
as specified in I(revoked_certificates).
|
||||
- If set to C(update), makes sure that the CRL contains the revoked certificates from
|
||||
I(revoked_certificates), but can also contain other revoked certificates. If the CRL file
|
||||
already exists, all entries from the existing CRL will also be included in the new CRL.
|
||||
When using C(update), you might be interested in setting I(ignore_timestamps) to C(yes).
|
||||
type: str
|
||||
default: generate
|
||||
choices: [ generate, update ]
|
||||
|
||||
force:
|
||||
description:
|
||||
- Should the CRL be forced to be regenerated.
|
||||
type: bool
|
||||
default: no
|
||||
|
||||
backup:
|
||||
description:
|
||||
- Create a backup file including a timestamp so you can get the original
|
||||
CRL back if you overwrote it with a new one by accident.
|
||||
type: bool
|
||||
default: no
|
||||
|
||||
path:
|
||||
description:
|
||||
- Remote absolute path where the generated CRL file should be created or is already located.
|
||||
type: path
|
||||
required: yes
|
||||
|
||||
privatekey_path:
|
||||
description:
|
||||
- Path to the CA's private key to use when signing the CRL.
|
||||
- Either I(privatekey_path) or I(privatekey_content) must be specified if I(state) is C(present), but not both.
|
||||
type: path
|
||||
|
||||
privatekey_content:
|
||||
description:
|
||||
- The content of the CA's private key to use when signing the CRL.
|
||||
- Either I(privatekey_path) or I(privatekey_content) must be specified if I(state) is C(present), but not both.
|
||||
type: str
|
||||
|
||||
privatekey_passphrase:
|
||||
description:
|
||||
- The passphrase for the I(privatekey_path).
|
||||
- This is required if the private key is password protected.
|
||||
type: str
|
||||
|
||||
issuer:
|
||||
description:
|
||||
- Key/value pairs that will be present in the issuer name field of the CRL.
|
||||
- If you need to specify more than one value with the same key, use a list as value.
|
||||
- Required if I(state) is C(present).
|
||||
type: dict
|
||||
|
||||
last_update:
|
||||
description:
|
||||
- The point in time from which this CRL can be trusted.
|
||||
- Time can be specified either as relative time or as absolute timestamp.
|
||||
- Time will always be interpreted as UTC.
|
||||
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
|
||||
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
|
||||
- Note that if using relative time this module is NOT idempotent, except when
|
||||
I(ignore_timestamps) is set to C(yes).
|
||||
type: str
|
||||
default: "+0s"
|
||||
|
||||
next_update:
|
||||
description:
|
||||
- "The absolute latest point in time by which this I(issuer) is expected to have issued
|
||||
another CRL. Many clients will treat a CRL as expired once I(next_update) occurs."
|
||||
- Time can be specified either as relative time or as absolute timestamp.
|
||||
- Time will always be interpreted as UTC.
|
||||
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
|
||||
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
|
||||
- Note that if using relative time this module is NOT idempotent, except when
|
||||
I(ignore_timestamps) is set to C(yes).
|
||||
- Required if I(state) is C(present).
|
||||
type: str
|
||||
|
||||
digest:
|
||||
description:
|
||||
- Digest algorithm to be used when signing the CRL.
|
||||
type: str
|
||||
default: sha256
|
||||
|
||||
revoked_certificates:
|
||||
description:
|
||||
- List of certificates to be revoked.
|
||||
- Required if I(state) is C(present).
|
||||
type: list
|
||||
elements: dict
|
||||
suboptions:
|
||||
path:
|
||||
description:
|
||||
- Path to a certificate in PEM format.
|
||||
- The serial number and issuer will be extracted from the certificate.
|
||||
- Mutually exclusive with I(content) and I(serial_number). One of these three options
|
||||
must be specified.
|
||||
type: path
|
||||
content:
|
||||
description:
|
||||
- Content of a certificate in PEM format.
|
||||
- The serial number and issuer will be extracted from the certificate.
|
||||
- Mutually exclusive with I(path) and I(serial_number). One of these three options
|
||||
must be specified.
|
||||
type: str
|
||||
serial_number:
|
||||
description:
|
||||
- Serial number of the certificate.
|
||||
- Mutually exclusive with I(path) and I(content). One of these three options must
|
||||
be specified.
|
||||
type: int
|
||||
revocation_date:
|
||||
description:
|
||||
- The point in time the certificate was revoked.
|
||||
- Time can be specified either as relative time or as absolute timestamp.
|
||||
- Time will always be interpreted as UTC.
|
||||
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
|
||||
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
|
||||
- Note that if using relative time this module is NOT idempotent, except when
|
||||
I(ignore_timestamps) is set to C(yes).
|
||||
type: str
|
||||
default: "+0s"
|
||||
issuer:
|
||||
description:
|
||||
- The certificate's issuer.
|
||||
- "Example: C(DNS:ca.example.org)"
|
||||
type: list
|
||||
elements: str
|
||||
issuer_critical:
|
||||
description:
|
||||
- Whether the certificate issuer extension should be critical.
|
||||
type: bool
|
||||
default: no
|
||||
reason:
|
||||
description:
|
||||
- The value for the revocation reason extension.
|
||||
type: str
|
||||
choices:
|
||||
- unspecified
|
||||
- key_compromise
|
||||
- ca_compromise
|
||||
- affiliation_changed
|
||||
- superseded
|
||||
- cessation_of_operation
|
||||
- certificate_hold
|
||||
- privilege_withdrawn
|
||||
- aa_compromise
|
||||
- remove_from_crl
|
||||
reason_critical:
|
||||
description:
|
||||
- Whether the revocation reason extension should be critical.
|
||||
type: bool
|
||||
default: no
|
||||
invalidity_date:
|
||||
description:
|
||||
- The point in time it was known/suspected that the private key was compromised
|
||||
or that the certificate otherwise became invalid.
|
||||
- Time can be specified either as relative time or as absolute timestamp.
|
||||
- Time will always be interpreted as UTC.
|
||||
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
|
||||
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
|
||||
- Note that if using relative time this module is NOT idempotent. This will NOT
|
||||
change when I(ignore_timestamps) is set to C(yes).
|
||||
type: str
|
||||
invalidity_date_critical:
|
||||
description:
|
||||
- Whether the invalidity date extension should be critical.
|
||||
type: bool
|
||||
default: no
|
||||
|
||||
ignore_timestamps:
|
||||
description:
|
||||
- Whether the timestamps I(last_update), I(next_update) and I(revocation_date) (in
|
||||
I(revoked_certificates)) should be ignored for idempotency checks. The timestamp
|
||||
I(invalidity_date) in I(revoked_certificates) will never be ignored.
|
||||
- Use this in combination with relative timestamps for these values to get idempotency.
|
||||
type: bool
|
||||
default: no
|
||||
|
||||
return_content:
|
||||
description:
|
||||
- If set to C(yes), will return the (current or generated) CRL's content as I(crl).
|
||||
type: bool
|
||||
default: no
|
||||
|
||||
extends_documentation_fragment:
|
||||
- files
|
||||
|
||||
notes:
|
||||
- All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern.
|
||||
- Date specified should be UTC. Minutes and seconds are mandatory.
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate a CRL
|
||||
x509_crl:
|
||||
path: /etc/ssl/my-ca.crl
|
||||
privatekey_path: /etc/ssl/private/my-ca.pem
|
||||
issuer:
|
||||
CN: My CA
|
||||
last_update: "+0s"
|
||||
next_update: "+7d"
|
||||
revoked_certificates:
|
||||
- serial_number: 1234
|
||||
revocation_date: 20190331202428Z
|
||||
issuer:
|
||||
CN: My CA
|
||||
- serial_number: 2345
|
||||
revocation_date: 20191013152910Z
|
||||
reason: affiliation_changed
|
||||
invalidity_date: 20191001000000Z
|
||||
- path: /etc/ssl/crt/revoked-cert.pem
|
||||
revocation_date: 20191010010203Z
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
filename:
|
||||
description: Path to the generated CRL
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /path/to/my-ca.crl
|
||||
backup_file:
|
||||
description: Name of backup file created.
|
||||
returned: changed and if I(backup) is C(yes)
|
||||
type: str
|
||||
sample: /path/to/my-ca.crl.2019-03-09@11:22~
|
||||
privatekey:
|
||||
description: Path to the private CA key
|
||||
returned: changed or success
|
||||
type: str
|
||||
sample: /path/to/my-ca.pem
|
||||
issuer:
|
||||
description:
|
||||
- The CRL's issuer.
|
||||
- Note that for repeated values, only the last one will be returned.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: '{"organizationName": "Ansible", "commonName": "ca.example.com"}'
|
||||
issuer_ordered:
|
||||
description: The CRL's issuer as an ordered list of tuples.
|
||||
returned: success
|
||||
type: list
|
||||
elements: list
|
||||
sample: '[["organizationName", "Ansible"], ["commonName": "ca.example.com"]]'
|
||||
last_update:
|
||||
description: The point in time from which this CRL can be trusted as ASN.1 TIME.
|
||||
returned: success
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
next_update:
|
||||
description: The point in time from which a new CRL will be issued and the client has to check for it as ASN.1 TIME.
|
||||
returned: success
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
digest:
|
||||
description: The signature algorithm used to sign the CRL.
|
||||
returned: success
|
||||
type: str
|
||||
sample: sha256WithRSAEncryption
|
||||
revoked_certificates:
|
||||
description: List of certificates to be revoked.
|
||||
returned: success
|
||||
type: list
|
||||
elements: dict
|
||||
contains:
|
||||
serial_number:
|
||||
description: Serial number of the certificate.
|
||||
type: int
|
||||
sample: 1234
|
||||
revocation_date:
|
||||
description: The point in time the certificate was revoked as ASN.1 TIME.
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
issuer:
|
||||
description: The certificate's issuer.
|
||||
type: list
|
||||
elements: str
|
||||
sample: '["DNS:ca.example.org"]'
|
||||
issuer_critical:
|
||||
description: Whether the certificate issuer extension is critical.
|
||||
type: bool
|
||||
sample: no
|
||||
reason:
|
||||
description:
|
||||
- The value for the revocation reason extension.
|
||||
- One of C(unspecified), C(key_compromise), C(ca_compromise), C(affiliation_changed), C(superseded),
|
||||
C(cessation_of_operation), C(certificate_hold), C(privilege_withdrawn), C(aa_compromise), and
|
||||
C(remove_from_crl).
|
||||
type: str
|
||||
sample: key_compromise
|
||||
reason_critical:
|
||||
description: Whether the revocation reason extension is critical.
|
||||
type: bool
|
||||
sample: no
|
||||
invalidity_date:
|
||||
description: |
|
||||
The point in time it was known/suspected that the private key was compromised
|
||||
or that the certificate otherwise became invalid as ASN.1 TIME.
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
invalidity_date_critical:
|
||||
description: Whether the invalidity date extension is critical.
|
||||
type: bool
|
||||
sample: no
|
||||
crl:
|
||||
description: The (current or generated) CRL's content.
|
||||
returned: if I(state) is C(present) and I(return_content) is C(yes)
|
||||
type: str
|
||||
'''
|
||||
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils._text import to_native, to_text
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2'
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import Encoding
|
||||
from cryptography.x509 import (
|
||||
CertificateRevocationListBuilder,
|
||||
RevokedCertificateBuilder,
|
||||
NameAttribute,
|
||||
Name,
|
||||
)
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
class CRLError(crypto_utils.OpenSSLObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class CRL(crypto_utils.OpenSSLObject):
|
||||
|
||||
def __init__(self, module):
|
||||
super(CRL, self).__init__(
|
||||
module.params['path'],
|
||||
module.params['state'],
|
||||
module.params['force'],
|
||||
module.check_mode
|
||||
)
|
||||
|
||||
self.update = module.params['mode'] == 'update'
|
||||
self.ignore_timestamps = module.params['ignore_timestamps']
|
||||
self.return_content = module.params['return_content']
|
||||
self.crl_content = None
|
||||
|
||||
self.privatekey_path = module.params['privatekey_path']
|
||||
self.privatekey_content = module.params['privatekey_content']
|
||||
if self.privatekey_content is not None:
|
||||
self.privatekey_content = self.privatekey_content.encode('utf-8')
|
||||
self.privatekey_passphrase = module.params['privatekey_passphrase']
|
||||
|
||||
self.issuer = crypto_utils.parse_name_field(module.params['issuer'])
|
||||
self.issuer = [(entry[0], entry[1]) for entry in self.issuer if entry[1]]
|
||||
|
||||
self.last_update = crypto_utils.get_relative_time_option(module.params['last_update'], 'last_update')
|
||||
self.next_update = crypto_utils.get_relative_time_option(module.params['next_update'], 'next_update')
|
||||
|
||||
self.digest = crypto_utils.select_message_digest(module.params['digest'])
|
||||
if self.digest is None:
|
||||
raise CRLError('The digest "{0}" is not supported'.format(module.params['digest']))
|
||||
|
||||
self.revoked_certificates = []
|
||||
for i, rc in enumerate(module.params['revoked_certificates']):
|
||||
result = {
|
||||
'serial_number': None,
|
||||
'revocation_date': None,
|
||||
'issuer': None,
|
||||
'issuer_critical': False,
|
||||
'reason': None,
|
||||
'reason_critical': False,
|
||||
'invalidity_date': None,
|
||||
'invalidity_date_critical': False,
|
||||
}
|
||||
path_prefix = 'revoked_certificates[{0}].'.format(i)
|
||||
if rc['path'] is not None or rc['content'] is not None:
|
||||
# Load certificate from file or content
|
||||
try:
|
||||
if rc['content'] is not None:
|
||||
rc['content'] = rc['content'].encode('utf-8')
|
||||
cert = crypto_utils.load_certificate(rc['path'], content=rc['content'], backend='cryptography')
|
||||
try:
|
||||
result['serial_number'] = cert.serial_number
|
||||
except AttributeError:
|
||||
# The property was called "serial" before cryptography 1.4
|
||||
result['serial_number'] = cert.serial
|
||||
except crypto_utils.OpenSSLObjectError as e:
|
||||
if rc['content'] is not None:
|
||||
module.fail_json(
|
||||
msg='Cannot parse certificate from {0}content: {1}'.format(path_prefix, to_native(e))
|
||||
)
|
||||
else:
|
||||
module.fail_json(
|
||||
msg='Cannot read certificate "{1}" from {0}path: {2}'.format(path_prefix, rc['path'], to_native(e))
|
||||
)
|
||||
else:
|
||||
# Specify serial_number (and potentially issuer) directly
|
||||
result['serial_number'] = rc['serial_number']
|
||||
# All other options
|
||||
if rc['issuer']:
|
||||
result['issuer'] = [crypto_utils.cryptography_get_name(issuer) for issuer in rc['issuer']]
|
||||
result['issuer_critical'] = rc['issuer_critical']
|
||||
result['revocation_date'] = crypto_utils.get_relative_time_option(
|
||||
rc['revocation_date'],
|
||||
path_prefix + 'revocation_date'
|
||||
)
|
||||
if rc['reason']:
|
||||
result['reason'] = crypto_utils.REVOCATION_REASON_MAP[rc['reason']]
|
||||
result['reason_critical'] = rc['reason_critical']
|
||||
if rc['invalidity_date']:
|
||||
result['invalidity_date'] = crypto_utils.get_relative_time_option(
|
||||
rc['invalidity_date'],
|
||||
path_prefix + 'invalidity_date'
|
||||
)
|
||||
result['invalidity_date_critical'] = rc['invalidity_date_critical']
|
||||
self.revoked_certificates.append(result)
|
||||
|
||||
self.module = module
|
||||
|
||||
self.backup = module.params['backup']
|
||||
self.backup_file = None
|
||||
|
||||
try:
|
||||
self.privatekey = crypto_utils.load_privatekey(
|
||||
path=self.privatekey_path,
|
||||
content=self.privatekey_content,
|
||||
passphrase=self.privatekey_passphrase,
|
||||
backend='cryptography'
|
||||
)
|
||||
except crypto_utils.OpenSSLBadPassphraseError as exc:
|
||||
raise CRLError(exc)
|
||||
|
||||
self.crl = None
|
||||
try:
|
||||
with open(self.path, 'rb') as f:
|
||||
data = f.read()
|
||||
self.crl = x509.load_pem_x509_crl(data, default_backend())
|
||||
if self.return_content:
|
||||
self.crl_content = data
|
||||
except Exception as dummy:
|
||||
self.crl_content = None
|
||||
|
||||
def remove(self):
|
||||
if self.backup:
|
||||
self.backup_file = self.module.backup_local(self.path)
|
||||
super(CRL, self).remove(self.module)
|
||||
|
||||
def _compress_entry(self, entry):
|
||||
if self.ignore_timestamps:
|
||||
# Throw out revocation_date
|
||||
return (
|
||||
entry['serial_number'],
|
||||
tuple(entry['issuer']) if entry['issuer'] is not None else None,
|
||||
entry['issuer_critical'],
|
||||
entry['reason'],
|
||||
entry['reason_critical'],
|
||||
entry['invalidity_date'],
|
||||
entry['invalidity_date_critical'],
|
||||
)
|
||||
else:
|
||||
return (
|
||||
entry['serial_number'],
|
||||
entry['revocation_date'],
|
||||
tuple(entry['issuer']) if entry['issuer'] is not None else None,
|
||||
entry['issuer_critical'],
|
||||
entry['reason'],
|
||||
entry['reason_critical'],
|
||||
entry['invalidity_date'],
|
||||
entry['invalidity_date_critical'],
|
||||
)
|
||||
|
||||
def check(self, perms_required=True):
|
||||
"""Ensure the resource is in its desired state."""
|
||||
|
||||
state_and_perms = super(CRL, self).check(self.module, perms_required)
|
||||
|
||||
if not state_and_perms:
|
||||
return False
|
||||
|
||||
if self.crl is None:
|
||||
return False
|
||||
|
||||
if self.last_update != self.crl.last_update and not self.ignore_timestamps:
|
||||
return False
|
||||
if self.next_update != self.crl.next_update and not self.ignore_timestamps:
|
||||
return False
|
||||
if self.digest.name != self.crl.signature_hash_algorithm.name:
|
||||
return False
|
||||
|
||||
want_issuer = [(crypto_utils.cryptography_name_to_oid(entry[0]), entry[1]) for entry in self.issuer]
|
||||
if want_issuer != [(sub.oid, sub.value) for sub in self.crl.issuer]:
|
||||
return False
|
||||
|
||||
old_entries = [self._compress_entry(crypto_utils.cryptography_decode_revoked_certificate(cert)) for cert in self.crl]
|
||||
new_entries = [self._compress_entry(cert) for cert in self.revoked_certificates]
|
||||
if self.update:
|
||||
# We don't simply use a set so that duplicate entries are treated correctly
|
||||
for entry in new_entries:
|
||||
try:
|
||||
old_entries.remove(entry)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
if old_entries != new_entries:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _generate_crl(self):
|
||||
backend = default_backend()
|
||||
crl = CertificateRevocationListBuilder()
|
||||
|
||||
try:
|
||||
crl = crl.issuer_name(Name([
|
||||
NameAttribute(crypto_utils.cryptography_name_to_oid(entry[0]), to_text(entry[1]))
|
||||
for entry in self.issuer
|
||||
]))
|
||||
except ValueError as e:
|
||||
raise CRLError(e)
|
||||
|
||||
crl = crl.last_update(self.last_update)
|
||||
crl = crl.next_update(self.next_update)
|
||||
|
||||
if self.update and self.crl:
|
||||
new_entries = set([self._compress_entry(entry) for entry in self.revoked_certificates])
|
||||
for entry in self.crl:
|
||||
decoded_entry = self._compress_entry(crypto_utils.cryptography_decode_revoked_certificate(entry))
|
||||
if decoded_entry not in new_entries:
|
||||
crl = crl.add_revoked_certificate(entry)
|
||||
for entry in self.revoked_certificates:
|
||||
revoked_cert = RevokedCertificateBuilder()
|
||||
revoked_cert = revoked_cert.serial_number(entry['serial_number'])
|
||||
revoked_cert = revoked_cert.revocation_date(entry['revocation_date'])
|
||||
if entry['issuer'] is not None:
|
||||
revoked_cert = revoked_cert.add_extension(
|
||||
x509.CertificateIssuer([
|
||||
crypto_utils.cryptography_get_name(name) for name in self.entry['issuer']
|
||||
]),
|
||||
entry['issuer_critical']
|
||||
)
|
||||
if entry['reason'] is not None:
|
||||
revoked_cert = revoked_cert.add_extension(
|
||||
x509.CRLReason(entry['reason']),
|
||||
entry['reason_critical']
|
||||
)
|
||||
if entry['invalidity_date'] is not None:
|
||||
revoked_cert = revoked_cert.add_extension(
|
||||
x509.InvalidityDate(entry['invalidity_date']),
|
||||
entry['invalidity_date_critical']
|
||||
)
|
||||
crl = crl.add_revoked_certificate(revoked_cert.build(backend))
|
||||
|
||||
self.crl = crl.sign(self.privatekey, self.digest, backend=backend)
|
||||
return self.crl.public_bytes(Encoding.PEM)
|
||||
|
||||
def generate(self):
|
||||
if not self.check(perms_required=False) or self.force:
|
||||
result = self._generate_crl()
|
||||
if self.return_content:
|
||||
self.crl_content = result
|
||||
if self.backup:
|
||||
self.backup_file = self.module.backup_local(self.path)
|
||||
crypto_utils.write_file(self.module, result)
|
||||
self.changed = True
|
||||
|
||||
file_args = self.module.load_file_common_arguments(self.module.params)
|
||||
if self.module.set_fs_attributes_if_different(file_args, False):
|
||||
self.changed = True
|
||||
|
||||
def _dump_revoked(self, entry):
|
||||
return {
|
||||
'serial_number': entry['serial_number'],
|
||||
'revocation_date': entry['revocation_date'].strftime(TIMESTAMP_FORMAT),
|
||||
'issuer':
|
||||
[crypto_utils.cryptography_decode_name(issuer) for issuer in entry['issuer']]
|
||||
if entry['issuer'] is not None else None,
|
||||
'issuer_critical': entry['issuer_critical'],
|
||||
'reason': crypto_utils.REVOCATION_REASON_MAP_INVERSE.get(entry['reason']) if entry['reason'] is not None else None,
|
||||
'reason_critical': entry['reason_critical'],
|
||||
'invalidity_date':
|
||||
entry['invalidity_date'].strftime(TIMESTAMP_FORMAT)
|
||||
if entry['invalidity_date'] is not None else None,
|
||||
'invalidity_date_critical': entry['invalidity_date_critical'],
|
||||
}
|
||||
|
||||
def dump(self, check_mode=False):
|
||||
result = {
|
||||
'changed': self.changed,
|
||||
'filename': self.path,
|
||||
'privatekey': self.privatekey_path,
|
||||
'last_update': None,
|
||||
'next_update': None,
|
||||
'digest': None,
|
||||
'issuer_ordered': None,
|
||||
'issuer': None,
|
||||
'revoked_certificates': [],
|
||||
}
|
||||
if self.backup_file:
|
||||
result['backup_file'] = self.backup_file
|
||||
|
||||
if check_mode:
|
||||
result['last_update'] = self.last_update.strftime(TIMESTAMP_FORMAT)
|
||||
result['next_update'] = self.next_update.strftime(TIMESTAMP_FORMAT)
|
||||
# result['digest'] = crypto_utils.cryptography_oid_to_name(self.crl.signature_algorithm_oid)
|
||||
result['digest'] = self.module.params['digest']
|
||||
result['issuer_ordered'] = self.issuer
|
||||
result['issuer'] = {}
|
||||
for k, v in self.issuer:
|
||||
result['issuer'][k] = v
|
||||
result['revoked_certificates'] = []
|
||||
for entry in self.revoked_certificates:
|
||||
result['revoked_certificates'].append(self._dump_revoked(entry))
|
||||
elif self.crl:
|
||||
result['last_update'] = self.crl.last_update.strftime(TIMESTAMP_FORMAT)
|
||||
result['next_update'] = self.crl.next_update.strftime(TIMESTAMP_FORMAT)
|
||||
try:
|
||||
result['digest'] = crypto_utils.cryptography_oid_to_name(self.crl.signature_algorithm_oid)
|
||||
except AttributeError:
|
||||
# Older cryptography versions don't have signature_algorithm_oid yet
|
||||
dotted = crypto_utils._obj2txt(
|
||||
self.crl._backend._lib,
|
||||
self.crl._backend._ffi,
|
||||
self.crl._x509_crl.sig_alg.algorithm
|
||||
)
|
||||
oid = x509.oid.ObjectIdentifier(dotted)
|
||||
result['digest'] = crypto_utils.cryptography_oid_to_name(oid)
|
||||
issuer = []
|
||||
for attribute in self.crl.issuer:
|
||||
issuer.append([crypto_utils.cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
result['issuer_ordered'] = issuer
|
||||
result['issuer'] = {}
|
||||
for k, v in issuer:
|
||||
result['issuer'][k] = v
|
||||
result['revoked_certificates'] = []
|
||||
for cert in self.crl:
|
||||
entry = crypto_utils.cryptography_decode_revoked_certificate(cert)
|
||||
result['revoked_certificates'].append(self._dump_revoked(entry))
|
||||
|
||||
if self.return_content:
|
||||
result['crl'] = self.crl_content
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
state=dict(type='str', default='present', choices=['present', 'absent']),
|
||||
mode=dict(type='str', default='generate', choices=['generate', 'update']),
|
||||
force=dict(type='bool', default=False),
|
||||
backup=dict(type='bool', default=False),
|
||||
path=dict(type='path', required=True),
|
||||
privatekey_path=dict(type='path'),
|
||||
privatekey_content=dict(type='str'),
|
||||
privatekey_passphrase=dict(type='str', no_log=True),
|
||||
issuer=dict(type='dict'),
|
||||
last_update=dict(type='str', default='+0s'),
|
||||
next_update=dict(type='str'),
|
||||
digest=dict(type='str', default='sha256'),
|
||||
ignore_timestamps=dict(type='bool', default=False),
|
||||
return_content=dict(type='bool', default=False),
|
||||
revoked_certificates=dict(
|
||||
type='list',
|
||||
elements='dict',
|
||||
options=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str'),
|
||||
serial_number=dict(type='int'),
|
||||
revocation_date=dict(type='str', default='+0s'),
|
||||
issuer=dict(type='list', elements='str'),
|
||||
issuer_critical=dict(type='bool', default=False),
|
||||
reason=dict(
|
||||
type='str',
|
||||
choices=[
|
||||
'unspecified', 'key_compromise', 'ca_compromise', 'affiliation_changed',
|
||||
'superseded', 'cessation_of_operation', 'certificate_hold',
|
||||
'privilege_withdrawn', 'aa_compromise', 'remove_from_crl'
|
||||
]
|
||||
),
|
||||
reason_critical=dict(type='bool', default=False),
|
||||
invalidity_date=dict(type='str'),
|
||||
invalidity_date_critical=dict(type='bool', default=False),
|
||||
),
|
||||
required_one_of=[['path', 'content', 'serial_number']],
|
||||
mutually_exclusive=[['path', 'content', 'serial_number']],
|
||||
),
|
||||
),
|
||||
required_if=[
|
||||
('state', 'present', ['privatekey_path', 'privatekey_content'], True),
|
||||
('state', 'present', ['issuer', 'next_update', 'revoked_certificates'], False),
|
||||
],
|
||||
mutually_exclusive=(
|
||||
['privatekey_path', 'privatekey_content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
add_file_common_args=True,
|
||||
)
|
||||
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
try:
|
||||
crl = CRL(module)
|
||||
|
||||
if module.params['state'] == 'present':
|
||||
if module.check_mode:
|
||||
result = crl.dump(check_mode=True)
|
||||
result['changed'] = module.params['force'] or not crl.check()
|
||||
module.exit_json(**result)
|
||||
|
||||
crl.generate()
|
||||
else:
|
||||
if module.check_mode:
|
||||
result = crl.dump(check_mode=True)
|
||||
result['changed'] = os.path.exists(module.params['path'])
|
||||
module.exit_json(**result)
|
||||
|
||||
crl.remove()
|
||||
|
||||
result = crl.dump()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
280
plugins/modules/x509_crl_info.py
Normal file
280
plugins/modules/x509_crl_info.py
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2020, 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
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||
'status': ['preview'],
|
||||
'supported_by': 'community'}
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: x509_crl_info
|
||||
short_description: Retrieve information on Certificate Revocation Lists (CRLs)
|
||||
description:
|
||||
- This module allows one to retrieve information on Certificate Revocation Lists (CRLs).
|
||||
requirements:
|
||||
- cryptography >= 1.2
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- Remote absolute path where the generated CRL file should be created or is already located.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: path
|
||||
content:
|
||||
description:
|
||||
- Content of the X.509 certificate in PEM format.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: str
|
||||
|
||||
notes:
|
||||
- All timestamp values are provided in ASN.1 TIME format, i.e. following the C(YYYYMMDDHHMMSSZ) pattern.
|
||||
They are all in UTC.
|
||||
seealso:
|
||||
- module: x509_crl
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Get information on CRL
|
||||
x509_crl_info:
|
||||
path: /etc/ssl/my-ca.crl
|
||||
register: result
|
||||
|
||||
- debug:
|
||||
msg: "{{ result }}"
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
issuer:
|
||||
description:
|
||||
- The CRL's issuer.
|
||||
- Note that for repeated values, only the last one will be returned.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: '{"organizationName": "Ansible", "commonName": "ca.example.com"}'
|
||||
issuer_ordered:
|
||||
description: The CRL's issuer as an ordered list of tuples.
|
||||
returned: success
|
||||
type: list
|
||||
elements: list
|
||||
sample: '[["organizationName", "Ansible"], ["commonName": "ca.example.com"]]'
|
||||
last_update:
|
||||
description: The point in time from which this CRL can be trusted as ASN.1 TIME.
|
||||
returned: success
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
next_update:
|
||||
description: The point in time from which a new CRL will be issued and the client has to check for it as ASN.1 TIME.
|
||||
returned: success
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
digest:
|
||||
description: The signature algorithm used to sign the CRL.
|
||||
returned: success
|
||||
type: str
|
||||
sample: sha256WithRSAEncryption
|
||||
revoked_certificates:
|
||||
description: List of certificates to be revoked.
|
||||
returned: success
|
||||
type: list
|
||||
elements: dict
|
||||
contains:
|
||||
serial_number:
|
||||
description: Serial number of the certificate.
|
||||
type: int
|
||||
sample: 1234
|
||||
revocation_date:
|
||||
description: The point in time the certificate was revoked as ASN.1 TIME.
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
issuer:
|
||||
description: The certificate's issuer.
|
||||
type: list
|
||||
elements: str
|
||||
sample: '["DNS:ca.example.org"]'
|
||||
issuer_critical:
|
||||
description: Whether the certificate issuer extension is critical.
|
||||
type: bool
|
||||
sample: no
|
||||
reason:
|
||||
description:
|
||||
- The value for the revocation reason extension.
|
||||
- One of C(unspecified), C(key_compromise), C(ca_compromise), C(affiliation_changed), C(superseded),
|
||||
C(cessation_of_operation), C(certificate_hold), C(privilege_withdrawn), C(aa_compromise), and
|
||||
C(remove_from_crl).
|
||||
type: str
|
||||
sample: key_compromise
|
||||
reason_critical:
|
||||
description: Whether the revocation reason extension is critical.
|
||||
type: bool
|
||||
sample: no
|
||||
invalidity_date:
|
||||
description: |
|
||||
The point in time it was known/suspected that the private key was compromised
|
||||
or that the certificate otherwise became invalid as ASN.1 TIME.
|
||||
type: str
|
||||
sample: 20190413202428Z
|
||||
invalidity_date_critical:
|
||||
description: Whether the invalidity date extension is critical.
|
||||
type: bool
|
||||
sample: no
|
||||
'''
|
||||
|
||||
|
||||
import traceback
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils import crypto as crypto_utils
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2'
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
class CRLError(crypto_utils.OpenSSLObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class CRLInfo(crypto_utils.OpenSSLObject):
|
||||
"""The main module implementation."""
|
||||
|
||||
def __init__(self, module):
|
||||
super(CRLInfo, self).__init__(
|
||||
module.params['path'] or '',
|
||||
'present',
|
||||
False,
|
||||
module.check_mode
|
||||
)
|
||||
|
||||
self.content = module.params['content']
|
||||
|
||||
self.module = module
|
||||
|
||||
self.crl = None
|
||||
if self.content is None:
|
||||
try:
|
||||
with open(self.path, 'rb') as f:
|
||||
data = f.read()
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e))
|
||||
else:
|
||||
data = self.content.encode('utf-8')
|
||||
|
||||
try:
|
||||
self.crl = x509.load_pem_x509_crl(data, default_backend())
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg='Error while decoding CRL: {0}'.format(e))
|
||||
|
||||
def _dump_revoked(self, entry):
|
||||
return {
|
||||
'serial_number': entry['serial_number'],
|
||||
'revocation_date': entry['revocation_date'].strftime(TIMESTAMP_FORMAT),
|
||||
'issuer':
|
||||
[crypto_utils.cryptography_decode_name(issuer) for issuer in entry['issuer']]
|
||||
if entry['issuer'] is not None else None,
|
||||
'issuer_critical': entry['issuer_critical'],
|
||||
'reason': crypto_utils.REVOCATION_REASON_MAP_INVERSE.get(entry['reason']) if entry['reason'] is not None else None,
|
||||
'reason_critical': entry['reason_critical'],
|
||||
'invalidity_date':
|
||||
entry['invalidity_date'].strftime(TIMESTAMP_FORMAT)
|
||||
if entry['invalidity_date'] is not None else None,
|
||||
'invalidity_date_critical': entry['invalidity_date_critical'],
|
||||
}
|
||||
|
||||
def get_info(self):
|
||||
result = {
|
||||
'changed': False,
|
||||
'last_update': None,
|
||||
'next_update': None,
|
||||
'digest': None,
|
||||
'issuer_ordered': None,
|
||||
'issuer': None,
|
||||
'revoked_certificates': [],
|
||||
}
|
||||
|
||||
result['last_update'] = self.crl.last_update.strftime(TIMESTAMP_FORMAT)
|
||||
result['next_update'] = self.crl.next_update.strftime(TIMESTAMP_FORMAT)
|
||||
try:
|
||||
result['digest'] = crypto_utils.cryptography_oid_to_name(self.crl.signature_algorithm_oid)
|
||||
except AttributeError:
|
||||
# Older cryptography versions don't have signature_algorithm_oid yet
|
||||
dotted = crypto_utils._obj2txt(
|
||||
self.crl._backend._lib,
|
||||
self.crl._backend._ffi,
|
||||
self.crl._x509_crl.sig_alg.algorithm
|
||||
)
|
||||
oid = x509.oid.ObjectIdentifier(dotted)
|
||||
result['digest'] = crypto_utils.cryptography_oid_to_name(oid)
|
||||
issuer = []
|
||||
for attribute in self.crl.issuer:
|
||||
issuer.append([crypto_utils.cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
result['issuer_ordered'] = issuer
|
||||
result['issuer'] = {}
|
||||
for k, v in issuer:
|
||||
result['issuer'][k] = v
|
||||
result['revoked_certificates'] = []
|
||||
for cert in self.crl:
|
||||
entry = crypto_utils.cryptography_decode_revoked_certificate(cert)
|
||||
result['revoked_certificates'].append(self._dump_revoked(entry))
|
||||
|
||||
return result
|
||||
|
||||
def generate(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
def dump(self):
|
||||
# Empty method because crypto_utils.OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str'),
|
||||
),
|
||||
required_one_of=(
|
||||
['path', 'content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['path', 'content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
|
||||
try:
|
||||
crl = CRLInfo(module)
|
||||
result = crl.get_info()
|
||||
module.exit_json(**result)
|
||||
except crypto_utils.OpenSSLObjectError as e:
|
||||
module.fail_json(msg=to_native(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user