Make all module_utils and plugin_utils private (#887)

* Add leading underscore. Remove deprecated module utils.

* Document module and plugin utils as private. Add changelog fragment.

* Convert relative to absolute imports.

* Remove unnecessary imports.
This commit is contained in:
Felix Fontein
2025-05-11 19:17:58 +02:00
committed by GitHub
parent f758d94fba
commit a5a4e022ba
146 changed files with 678 additions and 465 deletions

View File

@@ -0,0 +1,341 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from ansible.module_utils.common._collections_compat import Mapping
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException,
ModuleFailException,
)
if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import (
ACMEClient,
)
class ACMEAccount:
"""
ACME account object. Allows to create new accounts, check for existence of accounts,
retrieve account data.
"""
def __init__(self, client: ACMEClient) -> None:
# Set to true to enable logging of all signed requests
self._debug: bool = False
self.client = client
def _new_reg(
self,
contact: list[str] | None = None,
terms_agreed: bool = False,
allow_creation: bool = True,
external_account_binding: dict[str, t.Any] | None = None,
) -> tuple[bool, dict[str, t.Any] | None]:
"""
Registers a new ACME account. Returns a pair ``(created, data)``.
Here, ``created`` is ``True`` if the account was created and
``False`` if it already existed (e.g. it was not newly created),
or does not exist. In case the account was created or exists,
``data`` contains the account data; otherwise, it is ``None``.
If specified, ``external_account_binding`` should be a dictionary
with keys ``kid``, ``alg`` and ``key``
(https://tools.ietf.org/html/rfc8555#section-7.3.4).
https://tools.ietf.org/html/rfc8555#section-7.3
"""
contact = contact or []
if (
external_account_binding is not None
or self.client.directory["meta"].get("externalAccountRequired")
) and allow_creation:
# Some ACME servers such as ZeroSSL do not like it when you try to register an existing account
# and provide external_account_binding credentials. Thus we first send a request with allow_creation=False
# to see whether the account already exists.
# Note that we pass contact here: ZeroSSL does not accept registration calls without contacts, even
# if onlyReturnExisting is set to true.
created, data = self._new_reg(contact=contact, allow_creation=False)
if data:
# An account already exists! Return data
return created, data
# An account does not yet exist. Try to create one next.
new_reg: dict[str, t.Any] = {"contact": contact}
if not allow_creation:
# https://tools.ietf.org/html/rfc8555#section-7.3.1
new_reg["onlyReturnExisting"] = True
if terms_agreed:
new_reg["termsOfServiceAgreed"] = True
url = self.client.directory["newAccount"]
if external_account_binding is not None:
new_reg["externalAccountBinding"] = self.client.sign_request(
{
"alg": external_account_binding["alg"],
"kid": external_account_binding["kid"],
"url": url,
},
self.client.account_jwk,
self.client.backend.create_mac_key(
external_account_binding["alg"], external_account_binding["key"]
),
)
elif (
self.client.directory["meta"].get("externalAccountRequired")
and allow_creation
):
raise ModuleFailException(
"To create an account, an external account binding must be specified. "
"Use the acme_account module with the external_account_binding option."
)
result, info = self.client.send_signed_request(
url, new_reg, fail_on_error=False
)
if not isinstance(result, Mapping):
raise ACMEProtocolException(
self.client.module,
msg="Invalid account creation reply from ACME server",
info=info,
content_json=result,
)
if info["status"] == 201:
# Account did not exist
if "location" in info:
self.client.set_account_uri(info["location"])
return True, result
elif info["status"] == 200:
# Account did exist
if result.get("status") == "deactivated":
# A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and
# Boulder (https://github.com/letsencrypt/boulder/issues/3971): this should
# not return a valid account object according to
# https://tools.ietf.org/html/rfc8555#section-7.3.6:
# "Once an account is deactivated, the server MUST NOT accept further
# requests authorized by that account's key."
if not allow_creation:
return False, None
else:
raise ModuleFailException("Account is deactivated")
if "location" in info:
self.client.set_account_uri(info["location"])
return False, result
elif (
info["status"] in (400, 404)
and result["type"] == "urn:ietf:params:acme:error:accountDoesNotExist"
and not allow_creation
):
# Account does not exist (and we did not try to create it)
# (According to RFC 8555, Section 7.3.1, the HTTP status code MUST be 400.
# Unfortunately Digicert does not care and sends 404 instead.)
return False, None
elif (
info["status"] == 403
and result["type"] == "urn:ietf:params:acme:error:unauthorized"
and "deactivated" in (result.get("detail") or "")
):
# Account has been deactivated; currently works for Pebble; has not been
# implemented for Boulder (https://github.com/letsencrypt/boulder/issues/3971),
# might need adjustment in error detection.
if not allow_creation:
return False, None
else:
raise ModuleFailException("Account is deactivated")
else:
raise ACMEProtocolException(
self.client.module,
msg="Registering ACME account failed",
info=info,
content_json=result,
)
def get_account_data(self) -> dict[str, t.Any] | None:
"""
Retrieve account information. Can only be called when the account
URI is already known (such as after calling setup_account).
Return None if the account was deactivated, or a dict otherwise.
"""
if self.client.account_uri is None:
raise ModuleFailException("Account URI unknown")
# try POST-as-GET first (draft-15 or newer)
data: dict[str, t.Any] | None = None
result, info = self.client.send_signed_request(
self.client.account_uri, data, fail_on_error=False
)
# check whether that failed with a malformed request error
if (
info["status"] >= 400
and result.get("type") == "urn:ietf:params:acme:error:malformed"
):
# retry as a regular POST (with no changed data) for pre-draft-15 ACME servers
data = {}
result, info = self.client.send_signed_request(
self.client.account_uri, data, fail_on_error=False
)
if not isinstance(result, Mapping):
raise ACMEProtocolException(
self.client.module,
msg="Invalid account data retrieved from ACME server",
info=info,
content_json=result,
)
if (
info["status"] in (400, 403)
and result.get("type") == "urn:ietf:params:acme:error:unauthorized"
):
# Returned when account is deactivated
return None
if (
info["status"] in (400, 404)
and result.get("type") == "urn:ietf:params:acme:error:accountDoesNotExist"
):
# Returned when account does not exist
return None
if info["status"] < 200 or info["status"] >= 300:
raise ACMEProtocolException(
self.client.module,
msg="Error retrieving account data",
info=info,
content_json=result,
)
return result
@t.overload
def setup_account(
self,
contact: list[str] | None = None,
terms_agreed: bool = False,
allow_creation: t.Literal[True] = True,
remove_account_uri_if_not_exists: bool = False,
external_account_binding: dict[str, t.Any] | None = None,
) -> tuple[bool, dict[str, t.Any]]: ...
@t.overload
def setup_account(
self,
contact: list[str] | None = None,
terms_agreed: bool = False,
allow_creation: bool = True,
remove_account_uri_if_not_exists: bool = False,
external_account_binding: dict[str, t.Any] | None = None,
) -> tuple[bool, dict[str, t.Any] | None]: ...
def setup_account(
self,
contact: list[str] | None = None,
terms_agreed: bool = False,
allow_creation: bool = True,
remove_account_uri_if_not_exists: bool = False,
external_account_binding: dict[str, t.Any] | None = None,
) -> tuple[bool, dict[str, t.Any] | None]:
"""
Detect or create an account on the ACME server. For ACME v1,
as the only way (without knowing an account URI) to test if an
account exists is to try and create one with the provided account
key, this method will always result in an account being present
(except on error situations). For ACME v2, a new account will
only be created if ``allow_creation`` is set to True.
For ACME v2, ``check_mode`` is fully respected. For ACME v1, the
account might be created if it does not yet exist.
Return a pair ``(created, account_data)``. Here, ``created`` will
be ``True`` in case the account was created or would be created
(check mode). ``account_data`` will be the current account data,
or ``None`` if the account does not exist.
The account URI will be stored in ``client.account_uri``; if it is ``None``,
the account does not exist.
If specified, ``external_account_binding`` should be a dictionary
with keys ``kid``, ``alg`` and ``key``
(https://tools.ietf.org/html/rfc8555#section-7.3.4).
https://tools.ietf.org/html/rfc8555#section-7.3
"""
if self.client.account_uri is not None:
created = False
# Verify that the account key belongs to the URI.
# (If update_contact is True, this will be done below.)
account_data = self.get_account_data()
if account_data is None:
if remove_account_uri_if_not_exists and not allow_creation:
self.client.account_uri = None
else:
raise ModuleFailException(
"Account is deactivated or does not exist!"
)
else:
created, account_data = self._new_reg(
contact,
terms_agreed=terms_agreed,
allow_creation=allow_creation and not self.client.module.check_mode,
external_account_binding=external_account_binding,
)
if (
self.client.module.check_mode
and self.client.account_uri is None
and allow_creation
):
created = True
account_data = {"contact": contact or []}
return created, account_data
def update_account(
self, account_data: dict[str, t.Any], contact: list[str] | None = None
) -> tuple[bool, dict[str, t.Any]]:
"""
Update an account on the ACME server. Check mode is fully respected.
The current account data must be provided as ``account_data``.
Return a pair ``(updated, account_data)``, where ``updated`` is
``True`` in case something changed (contact info updated) or
would be changed (check mode), and ``account_data`` the updated
account data.
https://tools.ietf.org/html/rfc8555#section-7.3.2
"""
if self.client.account_uri is None:
raise ModuleFailException("Cannot update account without account URI")
# Create request
update_request: dict[str, t.Any] = {}
if contact is not None and account_data.get("contact", []) != contact:
update_request["contact"] = list(contact)
# No change?
if not update_request:
return False, dict(account_data)
# Apply change
if self.client.module.check_mode:
account_data = dict(account_data)
account_data.update(update_request)
else:
account_data, info = self.client.send_signed_request(
self.client.account_uri, update_request
)
if not isinstance(account_data, Mapping):
raise ACMEProtocolException(
self.client.module,
msg="Invalid account updating reply from ACME server",
info=info,
content_json=account_data,
)
return True, account_data

View File

@@ -0,0 +1,693 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import copy
import datetime
import json
import locale
import time
import typing as t
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.urls import fetch_url
from ansible_collections.community.crypto.plugins.module_utils._acme.backend_cryptography import (
CRYPTOGRAPHY_ERROR,
CRYPTOGRAPHY_MINIMAL_VERSION,
CRYPTOGRAPHY_VERSION,
HAS_CURRENT_CRYPTOGRAPHY,
CryptographyBackend,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.backend_openssl_cli import (
OpenSSLCLIBackend,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException,
KeyParsingError,
ModuleFailException,
NetworkException,
format_http_status,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
compute_cert_id,
nopad_b64,
parse_retry_after,
)
from ansible_collections.community.crypto.plugins.module_utils._argspec import (
ArgumentSpec,
)
if t.TYPE_CHECKING:
import os
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CertificateInformation,
CryptoBackend,
)
# -1 usually means connection problems
RETRY_STATUS_CODES = (-1, 408, 429, 503)
RETRY_COUNT = 10
def _decode_retry(
module: AnsibleModule, response: t.Any, info: dict[str, t.Any], retry_count: int
) -> bool:
if info["status"] not in RETRY_STATUS_CODES:
return False
if retry_count >= RETRY_COUNT:
raise ACMEProtocolException(
module,
msg=f"Giving up after {RETRY_COUNT} retries",
info=info,
response=response,
)
# 429 and 503 should have a Retry-After header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
try:
# TODO: use utils.parse_retry_after()
retry_after = min(max(1, int(info.get("retry-after", "10"))), 60)
except (TypeError, ValueError):
retry_after = 10
module.log(
f"Retrieved a {format_http_status(info['status'])} HTTP status on {info['url']}, retrying in {retry_after} seconds"
)
time.sleep(retry_after)
return True
def _assert_fetch_url_success(
module: AnsibleModule,
response: t.Any,
info: dict[str, t.Any],
allow_redirect: bool = False,
allow_client_error: bool = True,
allow_server_error: bool = True,
) -> None:
if info["status"] < 0:
raise NetworkException(msg=f"Failure downloading {info['url']}, {info['msg']}")
if (
(300 <= info["status"] < 400 and not allow_redirect)
or (400 <= info["status"] < 500 and not allow_client_error)
or (info["status"] >= 500 and not allow_server_error)
):
raise ACMEProtocolException(module, info=info, response=response)
def _is_failed(
info: dict[str, t.Any], expected_status_codes: t.Iterable[int] | None = None
) -> bool:
if info["status"] < 200 or info["status"] >= 400:
return True
if (
expected_status_codes is not None
and info["status"] not in expected_status_codes
):
return True
return False
class ACMEDirectory:
"""
The ACME server directory. Gives access to the available resources,
and allows to obtain a Replay-Nonce. The acme_directory URL
needs to support unauthenticated GET requests; ACME endpoints
requiring authentication are not supported.
https://tools.ietf.org/html/rfc8555#section-7.1.1
"""
def __init__(self, module: AnsibleModule, client: ACMEClient) -> None:
self.module = module
self.directory_root = module.params["acme_directory"]
self.version = module.params["acme_version"]
self.directory, dummy = client.get_request(self.directory_root, get_only=True)
self.request_timeout = module.params["request_timeout"]
# Check whether self.version matches what we expect
if self.version == 2:
for key in ("newNonce", "newAccount", "newOrder"):
if key not in self.directory:
raise ModuleFailException(
"ACME directory does not seem to follow protocol ACME v2"
)
# Make sure that 'meta' is always available
if "meta" not in self.directory:
self.directory["meta"] = {}
def __getitem__(self, key: str) -> t.Any:
return self.directory[key]
def __contains__(self, key: str) -> bool:
return key in self.directory
def get(self, key: str, default_value: t.Any = None) -> t.Any:
return self.directory.get(key, default_value)
def get_nonce(self, resource: str | None = None) -> str:
url = self.directory["newNonce"]
if resource is not None:
url = resource
retry_count = 0
while True:
response, info = fetch_url(
self.module, url, method="HEAD", timeout=self.request_timeout
)
if _decode_retry(self.module, response, info, retry_count):
retry_count += 1
continue
if info["status"] not in (200, 204):
raise NetworkException(
f"Failed to get replay-nonce, got status {format_http_status(info['status'])}"
)
if "replay-nonce" in info:
return info["replay-nonce"]
self.module.log(
f"HEAD to {url} did return status {format_http_status(info['status'])}, but no replay-nonce header!"
)
if retry_count >= 5:
raise ACMEProtocolException(
self.module,
msg="Was not able to obtain nonce, giving up after 5 retries",
info=info,
response=response,
)
retry_count += 1
def has_renewal_info_endpoint(self) -> bool:
return "renewalInfo" in self.directory
class ACMEClient:
"""
ACME client object. Handles the authorized communication with the
ACME server.
"""
def __init__(self, module: AnsibleModule, backend: CryptoBackend) -> None:
# Set to true to enable logging of all signed requests
self._debug = False
self.module = module
self.backend = backend
self.version = module.params["acme_version"]
# account_key path and content are mutually exclusive
self.account_key_file = module.params.get("account_key_src")
self.account_key_content = module.params.get("account_key_content")
self.account_key_passphrase = module.params.get("account_key_passphrase")
# Grab account URI from module parameters.
# Make sure empty string is treated as None.
self.account_uri = module.params.get("account_uri") or None
self.request_timeout = module.params["request_timeout"]
self.account_key_data = None
self.account_jwk = None
self.account_jws_header = None
if self.account_key_file is not None or self.account_key_content is not None:
try:
self.account_key_data = self.parse_key(
key_file=self.account_key_file,
key_content=self.account_key_content,
passphrase=self.account_key_passphrase,
)
except KeyParsingError as e:
raise ModuleFailException(f"Error while parsing account key: {e.msg}")
self.account_jwk = self.account_key_data["jwk"]
self.account_jws_header = {
"alg": self.account_key_data["alg"],
"jwk": self.account_jwk,
}
if self.account_uri:
# Make sure self.account_jws_header is updated
self.set_account_uri(self.account_uri)
self.directory = ACMEDirectory(module, self)
def set_account_uri(self, uri: str) -> None:
"""
Set account URI. For ACME v2, it needs to be used to sending signed
requests.
"""
self.account_uri = uri
if self.account_jws_header:
self.account_jws_header.pop("jwk", None)
self.account_jws_header["kid"] = self.account_uri
def parse_key(
self,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
"""
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
In case of an error, raises KeyParsingError.
"""
if key_file is None and key_content is None:
raise AssertionError("One of key_file and key_content must be specified!")
return self.backend.parse_key(key_file, key_content, passphrase=passphrase)
def sign_request(
self,
protected: dict[str, t.Any],
payload: str | dict[str, t.Any] | None,
key_data: dict[str, t.Any],
encode_payload: bool = True,
) -> dict[str, t.Any]:
"""
Signs an ACME request.
"""
try:
if payload is None:
# POST-as-GET
payload64 = ""
else:
# POST
if encode_payload:
payload = self.module.jsonify(payload).encode("utf8")
payload64 = nopad_b64(to_bytes(payload))
protected64 = nopad_b64(self.module.jsonify(protected).encode("utf8"))
except Exception as e:
raise ModuleFailException(
f"Failed to encode payload / headers as JSON: {e}"
)
return self.backend.sign(payload64, protected64, key_data)
def _log(self, msg: str, data: t.Any = None) -> None:
"""
Write arguments to acme.log when logging is enabled.
"""
if self._debug:
with open("acme.log", "ab") as f:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%s")
f.write(f"[{timestamp}] {msg}\n".encode("utf-8"))
if data is not None:
f.write(
f"{json.dumps(data, indent=2, sort_keys=True)}\n\n".encode(
"utf-8"
)
)
@t.overload
def send_signed_request(
self,
url: str,
payload: str | dict[str, t.Any] | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: t.Literal[True] = True,
encode_payload: bool = True,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[dict[str, t.Any], dict[str, t.Any]]: ...
@t.overload
def send_signed_request(
self,
url: str,
payload: str | dict[str, t.Any] | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: t.Literal[False],
encode_payload: bool = True,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[bytes, dict[str, t.Any]]: ...
def send_signed_request(
self,
url: str,
payload: str | dict[str, t.Any] | None,
*,
key_data: dict[str, t.Any] | None = None,
jws_header: dict[str, t.Any] | None = None,
parse_json_result: bool = True,
encode_payload: bool = True,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[dict[str, t.Any] | bytes, dict[str, t.Any]]:
"""
Sends a JWS signed HTTP POST request to the ACME server and returns
the response as dictionary (if parse_json_result is True) or in raw form
(if parse_json_result is False).
https://tools.ietf.org/html/rfc8555#section-6.2
If payload is None, a POST-as-GET is performed.
(https://tools.ietf.org/html/rfc8555#section-6.3)
"""
key_data = key_data or self.account_key_data
if key_data is None:
raise ModuleFailException("Missing key data")
jws_header = jws_header or self.account_jws_header
if jws_header is None:
raise ModuleFailException("Missing JWS header")
failed_tries = 0
while True:
protected = copy.deepcopy(jws_header)
protected["nonce"] = self.directory.get_nonce()
protected["url"] = url
self._log("URL", url)
self._log("protected", protected)
self._log("payload", payload)
data = self.sign_request(
protected, payload, key_data, encode_payload=encode_payload
)
self._log("signed request", data)
data = self.module.jsonify(data)
headers = {
"Content-Type": "application/jose+json",
}
resp, info = fetch_url(
self.module,
url,
data=data,
headers=headers,
method="POST",
timeout=self.request_timeout,
)
if _decode_retry(self.module, resp, info, failed_tries):
failed_tries += 1
continue
_assert_fetch_url_success(self.module, resp, info)
result = {}
try:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if resp.closed:
raise TypeError
content = resp.read()
except (AttributeError, TypeError):
content = info.pop("body", None)
if content or not parse_json_result:
if (
parse_json_result
and info["content-type"].startswith("application/json")
) or 400 <= info["status"] < 600:
try:
decoded_result = self.module.from_json(content.decode("utf8"))
self._log("parsed result", decoded_result)
# In case of badNonce error, try again (up to 5 times)
# (https://tools.ietf.org/html/rfc8555#section-6.7)
if all(
(
400 <= info["status"] < 600,
decoded_result.get("type")
== "urn:ietf:params:acme:error:badNonce",
failed_tries <= 5,
)
):
failed_tries += 1
continue
if parse_json_result:
result = decoded_result
else:
result = content
except ValueError:
raise NetworkException(
f"Failed to parse the ACME response: {url} {content}"
)
else:
result = content
if fail_on_error and _is_failed(
info, expected_status_codes=expected_status_codes
):
raise ACMEProtocolException(
self.module,
msg=error_msg,
info=info,
content=content,
content_json=result if parse_json_result else None,
)
return result, info
@t.overload
def get_request(
self,
uri: str,
*,
parse_json_result: t.Literal[True] = True,
headers: dict[str, str] | None = None,
get_only: bool = False,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[dict[str, t.Any], dict[str, t.Any]]: ...
@t.overload
def get_request(
self,
uri: str,
*,
parse_json_result: t.Literal[False],
headers: dict[str, str] | None = None,
get_only: bool = False,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[bytes, dict[str, t.Any]]: ...
def get_request(
self,
uri: str,
*,
parse_json_result: bool = True,
headers: dict[str, str] | None = None,
get_only: bool = False,
fail_on_error: bool = True,
error_msg: str | None = None,
expected_status_codes: t.Iterable[int] | None = None,
) -> tuple[dict[str, t.Any] | bytes, dict[str, t.Any]]:
"""
Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback
to GET if server replies with a status code of 405.
"""
if not get_only:
# Try POST-as-GET
content, info = self.send_signed_request(
uri, None, parse_json_result=False, fail_on_error=False
)
if info["status"] == 405:
# Instead, do unauthenticated GET
get_only = True
else:
# Do unauthenticated GET
get_only = True
if get_only:
# Perform unauthenticated GET
retry_count = 0
while True:
resp, info = fetch_url(
self.module,
uri,
method="GET",
headers=headers,
timeout=self.request_timeout,
)
if not _decode_retry(self.module, resp, info, retry_count):
break
retry_count += 1
_assert_fetch_url_success(self.module, resp, info)
try:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if resp.closed:
raise TypeError
content = resp.read()
except (AttributeError, TypeError):
content = info.pop("body", None)
# Process result
parsed_json_result = False
result: dict[str, t.Any] | bytes
if parse_json_result:
result = {}
if content:
if info["content-type"].startswith("application/json"):
try:
result = self.module.from_json(content.decode("utf8"))
parsed_json_result = True
except ValueError:
raise NetworkException(
f"Failed to parse the ACME response: {uri} {content!r}"
)
else:
result = content
else:
result = content
if fail_on_error and _is_failed(
info, expected_status_codes=expected_status_codes
):
raise ACMEProtocolException(
self.module,
msg=error_msg,
info=info,
content=content,
content_json=(
t.cast(dict[str, t.Any], result) if parsed_json_result else None
),
)
return result, info
def get_renewal_info(
self,
cert_id: str | None = None,
cert_info: CertificateInformation | None = None,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
include_retry_after: bool = False,
retry_after_relative_with_timezone: bool = True,
) -> dict[str, t.Any]:
if not self.directory.has_renewal_info_endpoint():
raise ModuleFailException(
"The ACME endpoint does not support ACME Renewal Information retrieval"
)
if cert_id is None:
cert_id = compute_cert_id(
self.backend,
cert_info=cert_info,
cert_filename=cert_filename,
cert_content=cert_content,
)
url = f"{self.directory.directory['renewalInfo'].rstrip('/')}/{cert_id}"
data, info = self.get_request(
url, parse_json_result=True, fail_on_error=True, get_only=True
)
# Include Retry-After header if asked for
if include_retry_after and "retry-after" in info:
try:
data["retryAfter"] = parse_retry_after(
info["retry-after"],
relative_with_timezone=retry_after_relative_with_timezone,
)
except ValueError:
pass
return data
def create_default_argspec(
with_account: bool = True,
require_account_key: bool = True,
with_certificate: bool = False,
) -> ArgumentSpec:
"""
Provides default argument spec for the options documented in the acme doc fragment.
"""
result = ArgumentSpec(
argument_spec=dict(
acme_directory=dict(type="str", required=True),
acme_version=dict(type="int", choices=[2], default=2),
validate_certs=dict(type="bool", default=True),
select_crypto_backend=dict(
type="str", default="auto", choices=["auto", "openssl", "cryptography"]
),
request_timeout=dict(type="int", default=10),
),
)
if with_account:
result.update_argspec(
account_key_src=dict(type="path", aliases=["account_key"]),
account_key_content=dict(type="str", no_log=True),
account_key_passphrase=dict(type="str", no_log=True),
account_uri=dict(type="str"),
)
if require_account_key:
result.update(required_one_of=[["account_key_src", "account_key_content"]])
result.update(mutually_exclusive=[["account_key_src", "account_key_content"]])
if with_certificate:
result.update_argspec(
csr=dict(type="path"),
csr_content=dict(type="str"),
)
result.update(
required_one_of=[["csr", "csr_content"]],
mutually_exclusive=[["csr", "csr_content"]],
)
return result
def create_backend(module: AnsibleModule, needs_acme_v2: bool = True) -> CryptoBackend:
backend = module.params["select_crypto_backend"]
# Backend autodetect
if backend == "auto":
backend = "cryptography" if HAS_CURRENT_CRYPTOGRAPHY else "openssl"
# Create backend object
module_backend: CryptoBackend
if backend == "cryptography":
if CRYPTOGRAPHY_ERROR is not None:
# Either we could not import cryptography at all, or there was an unexpected error
if CRYPTOGRAPHY_VERSION is None:
msg = missing_required_lib("cryptography")
else:
msg = f"Unexpected error while preparing cryptography: {CRYPTOGRAPHY_ERROR.splitlines()[-1]}"
module.fail_json(msg=msg, exception=CRYPTOGRAPHY_ERROR)
if not HAS_CURRENT_CRYPTOGRAPHY:
# We succeeded importing cryptography, but its version is too old.
mrl = missing_required_lib(
f"cryptography >= {CRYPTOGRAPHY_MINIMAL_VERSION}"
)
module.fail_json(
msg=f"Found cryptography, but only version {CRYPTOGRAPHY_VERSION}. {mrl}"
)
module.debug(
f"Using cryptography backend (library version {CRYPTOGRAPHY_VERSION})"
)
module_backend = CryptographyBackend(module)
elif backend == "openssl":
module.debug("Using OpenSSL binary backend")
module_backend = OpenSSLCLIBackend(module)
else:
module.fail_json(msg=f'Unknown crypto backend "{backend}"!')
# Check common module parameters
if not module.params["validate_certs"]:
module.warn(
"Disabling certificate validation for communications with ACME endpoint. "
"This should only be done for testing against a local ACME server for "
"development purposes, but *never* for production purposes."
)
# AnsibleModule() changes the locale, so change it back to C because we rely
# on datetime.datetime.strptime() when parsing certificate dates.
locale.setlocale(locale.LC_ALL, "C")
return module_backend

View File

@@ -0,0 +1,522 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import base64
import binascii
import os
import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CertificateInformation,
CryptoBackend,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
ChainMatcher,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
BackendException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.io import read_file
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE,
cryptography_name_to_oid,
get_not_valid_after,
get_not_valid_before,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
convert_int_to_bytes,
convert_int_to_hex,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
extract_first_pem,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
parse_name_field,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
add_or_remove_timezone,
)
from ansible_collections.community.crypto.plugins.module_utils._version import (
LooseVersion,
)
CRYPTOGRAPHY_MINIMAL_VERSION = "1.5"
CRYPTOGRAPHY_ERROR = None
try:
import cryptography
import cryptography.hazmat.backends
import cryptography.hazmat.primitives.asymmetric.ec
import cryptography.hazmat.primitives.asymmetric.padding
import cryptography.hazmat.primitives.asymmetric.rsa
import cryptography.hazmat.primitives.asymmetric.utils
import cryptography.hazmat.primitives.hashes
import cryptography.hazmat.primitives.hmac
import cryptography.hazmat.primitives.serialization
import cryptography.x509
import cryptography.x509.oid
except ImportError:
HAS_CURRENT_CRYPTOGRAPHY = False
CRYPTOGRAPHY_VERSION = None
CRYPTOGRAPHY_ERROR = traceback.format_exc()
else:
CRYPTOGRAPHY_VERSION = cryptography.__version__
HAS_CURRENT_CRYPTOGRAPHY = LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion(
CRYPTOGRAPHY_MINIMAL_VERSION
)
if t.TYPE_CHECKING:
import datetime
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
CertificateChain,
Criterium,
)
class CryptographyChainMatcher(ChainMatcher):
@staticmethod
def _parse_key_identifier(
key_identifier: str | None, name: str, criterium_idx: int, module: AnsibleModule
) -> bytes | None:
if key_identifier:
try:
return binascii.unhexlify(key_identifier.replace(":", ""))
except Exception:
if criterium_idx is None:
module.warn(
f"Criterium has invalid {name} value. Ignoring criterium."
)
else:
module.warn(
f"Criterium {criterium_idx} in select_chain has invalid {name} value. "
"Ignoring criterium."
)
return None
def __init__(self, criterium: Criterium, module: AnsibleModule) -> None:
self.criterium = criterium
self.test_certificates = criterium.test_certificates
self.subject: list[tuple[cryptography.x509.oid.ObjectIdentifier, str]] = []
self.issuer: list[tuple[cryptography.x509.oid.ObjectIdentifier, str]] = []
if criterium.subject:
self.subject = [
(cryptography_name_to_oid(k), to_native(v))
for k, v in parse_name_field(criterium.subject, "subject")
]
if criterium.issuer:
self.issuer = [
(cryptography_name_to_oid(k), to_native(v))
for k, v in parse_name_field(criterium.issuer, "issuer")
]
self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier(
criterium.subject_key_identifier,
"subject_key_identifier",
criterium.index,
module,
)
self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier(
criterium.authority_key_identifier,
"authority_key_identifier",
criterium.index,
module,
)
self.module = module
def _match_subject(
self,
x509_subject: cryptography.x509.Name,
match_subject: list[tuple[cryptography.x509.oid.ObjectIdentifier, str]],
) -> bool:
for oid, value in match_subject:
found = False
for attribute in x509_subject:
if attribute.oid == oid and value == to_native(attribute.value):
found = True
break
if not found:
return False
return True
def match(self, certificate: CertificateChain) -> bool:
"""
Check whether an alternate chain matches the specified criterium.
"""
chain = certificate.chain
if self.test_certificates == "last":
chain = chain[-1:]
elif self.test_certificates == "first":
chain = chain[:1]
for cert in chain:
try:
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert))
matches = True
if not self._match_subject(x509.subject, self.subject):
matches = False
if not self._match_subject(x509.issuer, self.issuer):
matches = False
if self.subject_key_identifier:
try:
ext_ski = x509.extensions.get_extension_for_class(
cryptography.x509.SubjectKeyIdentifier
)
if self.subject_key_identifier != ext_ski.value.digest:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if self.authority_key_identifier:
try:
ext_aki = x509.extensions.get_extension_for_class(
cryptography.x509.AuthorityKeyIdentifier
)
if (
self.authority_key_identifier
!= ext_aki.value.key_identifier
):
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if matches:
return True
except Exception as e:
self.module.warn(f"Error while loading certificate {cert}: {e}")
return False
class CryptographyBackend(CryptoBackend):
def __init__(self, module: AnsibleModule) -> None:
super(CryptographyBackend, self).__init__(
module, with_timezone=CRYPTOGRAPHY_TIMEZONE
)
def parse_key(
self,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
"""
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors.
"""
# If key_content is not given, read key_file
if key_content is None:
if key_file is None:
raise KeyParsingError(
"one of key_file and key_content must be specified"
)
b_key_content = read_file(key_file)
else:
b_key_content = to_bytes(key_content)
# Parse key
try:
key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
b_key_content,
password=to_bytes(passphrase) if passphrase is not None else None,
)
except Exception as e:
raise KeyParsingError(f"error while loading key: {e}")
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
rsa_pk = key.public_key().public_numbers()
return {
"key_obj": key,
"type": "rsa",
"alg": "RS256",
"jwk": {
"kty": "RSA",
"e": nopad_b64(convert_int_to_bytes(rsa_pk.e)),
"n": nopad_b64(convert_int_to_bytes(rsa_pk.n)),
},
"hash": "sha256",
}
elif isinstance(
key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey
):
ec_pk = key.public_key().public_numbers()
if ec_pk.curve.name == "secp256r1":
bits = 256
alg = "ES256"
hashalg = "sha256"
point_size = 32
curve = "P-256"
elif ec_pk.curve.name == "secp384r1":
bits = 384
alg = "ES384"
hashalg = "sha384"
point_size = 48
curve = "P-384"
elif ec_pk.curve.name == "secp521r1":
# Not yet supported on Let's Encrypt side, see
# https://github.com/letsencrypt/boulder/issues/2217
bits = 521
alg = "ES512"
hashalg = "sha512"
point_size = 66
curve = "P-521"
else:
raise KeyParsingError(f"unknown elliptic curve: {ec_pk.curve.name}")
num_bytes = (bits + 7) // 8
return {
"key_obj": key,
"type": "ec",
"alg": alg,
"jwk": {
"kty": "EC",
"crv": curve,
"x": nopad_b64(convert_int_to_bytes(ec_pk.x, count=num_bytes)),
"y": nopad_b64(convert_int_to_bytes(ec_pk.y, count=num_bytes)),
},
"hash": hashalg,
"point_size": point_size,
}
else:
raise KeyParsingError(f'unknown key type "{type(key)}"')
def sign(
self, payload64: str, protected64: str, key_data: dict[str, t.Any]
) -> dict[str, t.Any]:
sign_payload = f"{protected64}.{payload64}".encode("utf8")
hashalg: type[cryptography.hazmat.primitives.hashes.HashAlgorithm]
if "mac_obj" in key_data:
mac = key_data["mac_obj"]()
mac.update(sign_payload)
signature = mac.finalize()
elif isinstance(
key_data["key_obj"],
cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey,
):
padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15()
hashalg = cryptography.hazmat.primitives.hashes.SHA256
signature = key_data["key_obj"].sign(sign_payload, padding, hashalg())
elif isinstance(
key_data["key_obj"],
cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey,
):
if key_data["hash"] == "sha256":
hashalg = cryptography.hazmat.primitives.hashes.SHA256
elif key_data["hash"] == "sha384":
hashalg = cryptography.hazmat.primitives.hashes.SHA384
elif key_data["hash"] == "sha512":
hashalg = cryptography.hazmat.primitives.hashes.SHA512
ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg())
r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(
key_data["key_obj"].sign(sign_payload, ecdsa)
)
rr = convert_int_to_hex(r, 2 * key_data["point_size"])
ss = convert_int_to_hex(s, 2 * key_data["point_size"])
signature = binascii.unhexlify(rr) + binascii.unhexlify(ss)
return {
"protected": protected64,
"payload": payload64,
"signature": nopad_b64(signature),
}
def create_mac_key(self, alg: str, key: str) -> dict[str, t.Any]:
"""Create a MAC key."""
hashalg: type[cryptography.hazmat.primitives.hashes.HashAlgorithm]
if alg == "HS256":
hashalg = cryptography.hazmat.primitives.hashes.SHA256
hashbytes = 32
elif alg == "HS384":
hashalg = cryptography.hazmat.primitives.hashes.SHA384
hashbytes = 48
elif alg == "HS512":
hashalg = cryptography.hazmat.primitives.hashes.SHA512
hashbytes = 64
else:
raise BackendException(
f"Unsupported MAC key algorithm for cryptography backend: {alg}"
)
key_bytes = base64.urlsafe_b64decode(key)
if len(key_bytes) < hashbytes:
raise BackendException(
f"{alg} key must be at least {hashbytes} bytes long (after Base64 decoding)"
)
return {
"mac_obj": lambda: cryptography.hazmat.primitives.hmac.HMAC(
key_bytes, hashalg()
),
"type": "hmac",
"alg": alg,
"jwk": {
"kty": "oct",
"k": key,
},
}
def get_ordered_csr_identifiers(
self,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> list[tuple[str, str]]:
"""
Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
The list is deduplicated, and if a CNAME is present, it will be returned
as the first element in the result.
"""
if csr_content is None:
if csr_filename is None:
raise BackendException(
"One of csr_content and csr_filename has to be provided"
)
b_csr_content = read_file(csr_filename)
else:
b_csr_content = to_bytes(csr_content)
csr = cryptography.x509.load_pem_x509_csr(b_csr_content)
identifiers = set()
result = []
def add_identifier(identifier: tuple[str, str]) -> None:
if identifier in identifiers:
return
identifiers.add(identifier)
result.append(identifier)
for sub in csr.subject:
if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME:
add_identifier(("dns", t.cast(str, sub.value)))
for extension in csr.extensions:
if (
extension.oid
== cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
):
for name in extension.value:
if isinstance(name, cryptography.x509.DNSName):
add_identifier(("dns", name.value))
elif isinstance(name, cryptography.x509.IPAddress):
add_identifier(("ip", name.value.compressed))
else:
raise BackendException(
f"Found unsupported SAN identifier {name}"
)
return result
def get_csr_identifiers(
self,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | bytes | None = None,
) -> set[tuple[str, str]]:
"""
Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
"""
return set(
self.get_ordered_csr_identifiers(
csr_filename=csr_filename, csr_content=csr_content
)
)
def get_cert_days(
self,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
now: datetime.datetime | None = None,
) -> int:
"""
Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one
certificate, only the first one will be considered.
If now is not specified, datetime.datetime.now() is used.
"""
if cert_filename is not None:
cert_content = None
if os.path.exists(cert_filename):
cert_content = read_file(cert_filename)
else:
cert_content = to_bytes(cert_content)
if cert_content is None:
return -1
# Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf.
b_cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or "")
try:
cert = cryptography.x509.load_pem_x509_certificate(b_cert_content)
except Exception as e:
if cert_filename is None:
raise BackendException(f"Cannot parse certificate: {e}")
raise BackendException(f"Cannot parse certificate {cert_filename}: {e}")
if now is None:
now = self.get_now()
else:
now = add_or_remove_timezone(now, with_timezone=CRYPTOGRAPHY_TIMEZONE)
return (get_not_valid_after(cert) - now).days
def create_chain_matcher(self, criterium: Criterium) -> ChainMatcher:
"""
Given a Criterium object, creates a ChainMatcher object.
"""
return CryptographyChainMatcher(criterium, self.module)
def get_cert_information(
self,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
) -> CertificateInformation:
"""
Return some information on a X.509 certificate as a CertificateInformation object.
"""
if cert_filename is not None:
cert_content = read_file(cert_filename)
else:
cert_content = to_bytes(cert_content)
# Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf.
b_cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or "")
try:
cert = cryptography.x509.load_pem_x509_certificate(b_cert_content)
except Exception as e:
if cert_filename is None:
raise BackendException(f"Cannot parse certificate: {e}")
raise BackendException(f"Cannot parse certificate {cert_filename}: {e}")
ski = None
try:
ext_ski = cert.extensions.get_extension_for_class(
cryptography.x509.SubjectKeyIdentifier
)
ski = ext_ski.value.digest
except cryptography.x509.ExtensionNotFound:
pass
aki = None
try:
ext_aki = cert.extensions.get_extension_for_class(
cryptography.x509.AuthorityKeyIdentifier
)
aki = ext_aki.value.key_identifier
except cryptography.x509.ExtensionNotFound:
pass
return CertificateInformation(
not_valid_after=get_not_valid_after(cert),
not_valid_before=get_not_valid_before(cert),
serial_number=cert.serial_number,
subject_key_identifier=ski,
authority_key_identifier=aki,
)

View File

@@ -0,0 +1,607 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import base64
import binascii
import datetime
import ipaddress
import os
import re
import tempfile
import traceback
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CertificateInformation,
CryptoBackend,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
BackendException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
convert_bytes_to_int,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
ensure_utc_timezone,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
Criterium,
)
_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C")
def _extract_date(
out_text: str, name: str, cert_filename_suffix: str = ""
) -> datetime.datetime:
matcher = re.search(rf"\s+{name}\s*:\s+(.*)", out_text)
if matcher is None:
raise BackendException(f"No '{name}' date found{cert_filename_suffix}")
date_str = matcher.group(1)
try:
# For some reason Python's strptime() does not return any timezone information,
# even though the information is there and a supported timezone for all supported
# Python implementations (GMT). So we have to modify the datetime object by
# replacing it by UTC.
return ensure_utc_timezone(
datetime.datetime.strptime(date_str, "%b %d %H:%M:%S %Y %Z")
)
except ValueError as exc:
raise BackendException(
f"Failed to parse '{name}' date{cert_filename_suffix}: {exc}"
)
def _decode_octets(octets_text: str) -> bytes:
return binascii.unhexlify(re.sub(r"(\s|:)", "", octets_text).encode("utf-8"))
@t.overload
def _extract_octets(
out_text: str,
name: str,
required: t.Literal[False],
potential_prefixes: t.Iterable[str] | None = None,
) -> bytes | None: ...
@t.overload
def _extract_octets(
out_text: str,
name: str,
required: t.Literal[True],
potential_prefixes: t.Iterable[str] | None = None,
) -> bytes: ...
def _extract_octets(
out_text: str,
name: str,
required: bool = True,
potential_prefixes: t.Iterable[str] | None = None,
) -> bytes | None:
part = (
f"(?:{'|'.join(re.escape(pp) for pp in potential_prefixes)})"
if potential_prefixes
else ""
)
regexp = rf"\s+{name}:\s*\n\s+{part}([A-Fa-f0-9]{{2}}(?::[A-Fa-f0-9]{{2}})*)\s*\n"
match = re.search(regexp, out_text, re.MULTILINE | re.DOTALL)
if match is not None:
return _decode_octets(match.group(1))
if not required:
return None
raise BackendException(f"No '{name}' octet string found")
class OpenSSLCLIBackend(CryptoBackend):
def __init__(
self, module: AnsibleModule, openssl_binary: str | None = None
) -> None:
super(OpenSSLCLIBackend, self).__init__(module, with_timezone=True)
if openssl_binary is None:
openssl_binary = module.get_bin_path("openssl", True)
self.openssl_binary = openssl_binary
def parse_key(
self,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
"""
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors.
"""
if passphrase is not None:
raise KeyParsingError("openssl backend does not support key passphrases")
# If key_file is not given, but key_content, write that to a temporary file
if key_file is None:
if key_content is None:
raise KeyParsingError(
"one of key_file and key_content must be specified"
)
fd, tmpsrc = tempfile.mkstemp()
self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
f = os.fdopen(fd, "wb")
try:
f.write(key_content.encode("utf-8"))
key_file = tmpsrc
except Exception as err:
try:
f.close()
except Exception:
pass
raise KeyParsingError(
f"failed to create temporary content file: {err}",
exception=traceback.format_exc(),
)
f.close()
# Parse key
account_key_type = None
with open(key_file, "rt") as fi:
for line in fi:
m = re.match(
r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line
)
if m is not None:
account_key_type = m.group(1).lower()
break
if account_key_type is None:
# This happens for example if openssl_privatekey created this key
# (as opposed to the OpenSSL binary). For now, we assume this is
# an RSA key.
# FIXME: add some kind of auto-detection
account_key_type = "rsa"
if account_key_type not in ("rsa", "ec"):
raise KeyParsingError(f'unknown key type "{account_key_type}"')
openssl_keydump_cmd = [
self.openssl_binary,
account_key_type,
"-in",
str(key_file),
"-noout",
"-text",
]
rc, out, stderr = self.module.run_command(
openssl_keydump_cmd,
check_rc=False,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
)
if rc != 0:
raise BackendException(
f"Error while running {' '.join(openssl_keydump_cmd)}: {stderr}"
)
out_text = to_text(out, errors="surrogate_or_strict")
if account_key_type == "rsa":
matcher = re.search(
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent",
out_text,
re.MULTILINE | re.DOTALL,
)
if matcher is None:
raise KeyParsingError("cannot parse RSA key: modulus not found")
pub_hex = matcher.group(1)
matcher = re.search(
r"\npublicExponent: ([0-9]+)", out_text, re.MULTILINE | re.DOTALL
)
if matcher is None:
raise KeyParsingError("cannot parse RSA key: public exponent not found")
pub_exp = matcher.group(1)
pub_exp = f"{int(pub_exp):x}"
if len(pub_exp) % 2:
pub_exp = f"0{pub_exp}"
return {
"key_file": str(key_file),
"type": "rsa",
"alg": "RS256",
"jwk": {
"kty": "RSA",
"e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
"n": nopad_b64(_decode_octets(pub_hex)),
},
"hash": "sha256",
}
elif account_key_type == "ec":
pub_data = re.search(
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
out_text,
re.MULTILINE | re.DOTALL,
)
if pub_data is None:
raise KeyParsingError("cannot parse elliptic curve key")
pub_hex = _decode_octets(pub_data.group(1))
asn1_oid_curve = pub_data.group(2).lower()
nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None
if asn1_oid_curve == "prime256v1" or nist_curve == "p-256":
bits = 256
alg = "ES256"
hashalg = "sha256"
point_size = 32
curve = "P-256"
elif asn1_oid_curve == "secp384r1" or nist_curve == "p-384":
bits = 384
alg = "ES384"
hashalg = "sha384"
point_size = 48
curve = "P-384"
elif asn1_oid_curve == "secp521r1" or nist_curve == "p-521":
# Not yet supported on Let's Encrypt side, see
# https://github.com/letsencrypt/boulder/issues/2217
bits = 521
alg = "ES512"
hashalg = "sha512"
point_size = 66
curve = "P-521"
else:
raise KeyParsingError(
f"unknown elliptic curve: {asn1_oid_curve} / {nist_curve}"
)
num_bytes = (bits + 7) // 8
if len(pub_hex) != 2 * num_bytes:
raise KeyParsingError(
f"bad elliptic curve point ({asn1_oid_curve} / {nist_curve})"
)
return {
"key_file": key_file,
"type": "ec",
"alg": alg,
"jwk": {
"kty": "EC",
"crv": curve,
"x": nopad_b64(pub_hex[:num_bytes]),
"y": nopad_b64(pub_hex[num_bytes:]),
},
"hash": hashalg,
"point_size": point_size,
}
raise KeyParsingError(
f"Internal error: unexpected account_key_type = {account_key_type!r}"
)
def sign(
self, payload64: str, protected64: str, key_data: dict[str, t.Any]
) -> dict[str, t.Any]:
sign_payload = f"{protected64}.{payload64}".encode("utf8")
if key_data["type"] == "hmac":
hex_key = (
binascii.hexlify(base64.urlsafe_b64decode(key_data["jwk"]["k"]))
).decode("ascii")
cmd_postfix = [
"-mac",
"hmac",
"-macopt",
f"hexkey:{hex_key}",
"-binary",
]
else:
cmd_postfix = ["-sign", key_data["key_file"]]
openssl_sign_cmd = [
self.openssl_binary,
"dgst",
f"-{key_data['hash']}",
] + cmd_postfix
rc, out, err = self.module.run_command(
openssl_sign_cmd,
data=sign_payload,
check_rc=False,
binary_data=True,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
)
if rc != 0:
raise BackendException(
f"Error while running {' '.join(openssl_sign_cmd)}: {err}"
)
if key_data["type"] == "ec":
dummy, der_out, dummy = self.module.run_command(
[self.openssl_binary, "asn1parse", "-inform", "DER"],
data=out,
binary_data=True,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
)
expected_len = 2 * key_data["point_size"]
sig = re.findall(
rf"prim:\s+INTEGER\s+:([0-9A-F]{{1,{expected_len}}})\n",
to_text(der_out, errors="surrogate_or_strict"),
)
if len(sig) != 2:
der_output = to_text(der_out, errors="surrogate_or_strict")
raise BackendException(
f"failed to generate Elliptic Curve signature; cannot parse DER output: {der_output}"
)
sig[0] = (expected_len - len(sig[0])) * "0" + sig[0]
sig[1] = (expected_len - len(sig[1])) * "0" + sig[1]
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
return {
"protected": protected64,
"payload": payload64,
"signature": nopad_b64(to_bytes(out)),
}
def create_mac_key(self, alg: str, key: str) -> dict[str, t.Any]:
"""Create a MAC key."""
if alg == "HS256":
hashalg = "sha256"
hashbytes = 32
elif alg == "HS384":
hashalg = "sha384"
hashbytes = 48
elif alg == "HS512":
hashalg = "sha512"
hashbytes = 64
else:
raise BackendException(
f"Unsupported MAC key algorithm for OpenSSL backend: {alg}"
)
key_bytes = base64.urlsafe_b64decode(key)
if len(key_bytes) < hashbytes:
raise BackendException(
f"{alg} key must be at least {hashbytes} bytes long (after Base64 decoding)"
)
return {
"type": "hmac",
"alg": alg,
"jwk": {
"kty": "oct",
"k": key,
},
"hash": hashalg,
}
@staticmethod
def _normalize_ip(ip: str) -> str:
try:
return ipaddress.ip_address(ip).compressed
except ValueError:
# We do not want to error out on something IPAddress() cannot parse
return ip
def get_ordered_csr_identifiers(
self,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> list[tuple[str, str]]:
"""
Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
The list is deduplicated, and if a CNAME is present, it will be returned
as the first element in the result.
"""
filename = csr_filename
data = None
if csr_content is not None:
filename = "/dev/stdin"
data = to_bytes(csr_content)
openssl_csr_cmd = [
self.openssl_binary,
"req",
"-in",
str(filename),
"-noout",
"-text",
]
rc, out, err = self.module.run_command(
openssl_csr_cmd,
data=data,
check_rc=False,
binary_data=True,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
)
if rc != 0:
raise BackendException(
f"Error while running {' '.join(openssl_csr_cmd)}: {err}"
)
identifiers = set()
result = []
def add_identifier(identifier: tuple[str, str]) -> None:
if identifier in identifiers:
return
identifiers.add(identifier)
result.append(identifier)
common_name = re.search(
r"Subject:.* CN\s?=\s?([^\s,;/]+)",
to_text(out, errors="surrogate_or_strict"),
)
if common_name is not None:
add_identifier(("dns", common_name.group(1)))
subject_alt_names = re.search(
r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n",
to_text(out, errors="surrogate_or_strict"),
re.MULTILINE | re.DOTALL,
)
if subject_alt_names is not None:
for san in subject_alt_names.group(1).split(", "):
if san.lower().startswith("dns:"):
add_identifier(("dns", san[4:]))
elif san.lower().startswith("ip:"):
add_identifier(("ip", self._normalize_ip(san[3:])))
elif san.lower().startswith("ip address:"):
add_identifier(("ip", self._normalize_ip(san[11:])))
else:
raise BackendException(f'Found unsupported SAN identifier "{san}"')
return result
def get_csr_identifiers(
self,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> set[tuple[str, str]]:
"""
Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
"""
return set(
self.get_ordered_csr_identifiers(
csr_filename=csr_filename, csr_content=csr_content
)
)
def get_cert_days(
self,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
now: datetime.datetime | None = None,
) -> int:
"""
Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one
certificate, only the first one will be considered.
If now is not specified, datetime.datetime.now() is used.
"""
filename = cert_filename
data = None
if cert_content is not None:
filename = "/dev/stdin"
data = to_bytes(cert_content)
cert_filename_suffix = ""
elif cert_filename is not None:
if not os.path.exists(cert_filename):
return -1
cert_filename_suffix = f" in {cert_filename}"
else:
return -1
openssl_cert_cmd = [
self.openssl_binary,
"x509",
"-in",
str(filename),
"-noout",
"-text",
]
rc, out, err = self.module.run_command(
openssl_cert_cmd,
data=data,
check_rc=False,
binary_data=True,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
)
if rc != 0:
raise BackendException(
f"Error while running {' '.join(openssl_cert_cmd)}: {err}"
)
out_text = to_text(out, errors="surrogate_or_strict")
not_after = _extract_date(
out_text, "Not After", cert_filename_suffix=cert_filename_suffix
)
if now is None:
now = self.get_now()
else:
now = ensure_utc_timezone(now)
return (not_after - now).days
def create_chain_matcher(self, criterium: Criterium) -> t.NoReturn:
"""
Given a Criterium object, creates a ChainMatcher object.
"""
raise BackendException(
'Alternate chain matching can only be used with the "cryptography" backend.'
)
def get_cert_information(
self,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
) -> CertificateInformation:
"""
Return some information on a X.509 certificate as a CertificateInformation object.
"""
filename = cert_filename
data = None
if cert_filename is not None:
cert_filename_suffix = f" in {cert_filename}"
else:
filename = "/dev/stdin"
data = to_bytes(cert_content)
cert_filename_suffix = ""
openssl_cert_cmd = [
self.openssl_binary,
"x509",
"-in",
str(filename),
"-noout",
"-text",
]
rc, out, err = self.module.run_command(
openssl_cert_cmd,
data=data,
check_rc=False,
binary_data=True,
environ_update=_OPENSSL_ENVIRONMENT_UPDATE,
)
if rc != 0:
raise BackendException(
f"Error while running {' '.join(openssl_cert_cmd)}: {err}"
)
out_text = to_text(out, errors="surrogate_or_strict")
not_after = _extract_date(
out_text, "Not After", cert_filename_suffix=cert_filename_suffix
)
not_before = _extract_date(
out_text, "Not Before", cert_filename_suffix=cert_filename_suffix
)
sn = re.search(
r" Serial Number: ([0-9]+)",
to_text(out, errors="surrogate_or_strict"),
re.MULTILINE | re.DOTALL,
)
if sn:
serial = int(sn.group(1))
else:
serial = convert_bytes_to_int(
_extract_octets(out_text, "Serial Number", required=True)
)
ski = _extract_octets(out_text, "X509v3 Subject Key Identifier", required=False)
aki = _extract_octets(
out_text,
"X509v3 Authority Key Identifier",
required=False,
potential_prefixes=["keyid:", ""],
)
return CertificateInformation(
not_valid_after=not_after,
not_valid_before=not_before,
serial_number=serial,
subject_key_identifier=ski,
authority_key_identifier=aki,
)

View File

@@ -0,0 +1,219 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import datetime
import re
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
BackendException,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
UTC,
ensure_utc_timezone,
from_epoch_seconds,
get_epoch_seconds,
get_now_datetime,
get_relative_time_option,
remove_timezone,
)
if t.TYPE_CHECKING:
import os
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
ChainMatcher,
Criterium,
)
class CertificateInformation(t.NamedTuple):
not_valid_after: datetime.datetime
not_valid_before: datetime.datetime
serial_number: int
subject_key_identifier: bytes | None
authority_key_identifier: bytes | None
_FRACTIONAL_MATCHER = re.compile(
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(|\.\d+)(Z|[+-]\d{2}:?\d{2}.*)$"
)
def _reduce_fractional_digits(timestamp_str: str) -> str:
"""
Given a RFC 3339 timestamp that includes too many digits for the fractional seconds part, reduces these to at most 6.
"""
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
m = _FRACTIONAL_MATCHER.match(timestamp_str)
if not m:
raise BackendException(f"Cannot parse ISO 8601 timestamp {timestamp_str!r}")
timestamp, fractional, timezone = m.groups()
if len(fractional) > 7:
# Python does not support anything smaller than microseconds
# (Golang supports nanoseconds, Boulder often emits more fractional digits, which Python chokes on)
fractional = fractional[:7]
return f"{timestamp}{fractional}{timezone}"
def _parse_acme_timestamp(timestamp_str: str, with_timezone: bool) -> datetime.datetime:
"""
Parses a RFC 3339 timestamp.
"""
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
timestamp_str = _reduce_fractional_digits(timestamp_str)
for format in (
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f%z",
):
try:
result = datetime.datetime.strptime(timestamp_str, format)
except ValueError:
pass
else:
return (
ensure_utc_timezone(result)
if with_timezone
else remove_timezone(result)
)
raise BackendException(f"Cannot parse ISO 8601 timestamp {timestamp_str!r}")
class CryptoBackend(metaclass=abc.ABCMeta):
def __init__(self, module: AnsibleModule, with_timezone: bool = False) -> None:
self.module = module
self._with_timezone = with_timezone
def get_now(self) -> datetime.datetime:
return get_now_datetime(with_timezone=self._with_timezone)
def parse_acme_timestamp(self, timestamp_str: str) -> datetime.datetime:
# RFC 3339 (https://www.rfc-editor.org/info/rfc3339)
return _parse_acme_timestamp(timestamp_str, with_timezone=self._with_timezone)
def parse_module_parameter(self, value: str, name: str) -> datetime.datetime:
try:
result = get_relative_time_option(
value, name, with_timezone=self._with_timezone
)
if result is None:
raise BackendException(f"Invalid value for {name}: {value!r}")
return result
except OpenSSLObjectError as exc:
raise BackendException(str(exc))
def interpolate_timestamp(
self,
timestamp_start: datetime.datetime,
timestamp_end: datetime.datetime,
percentage: float,
) -> datetime.datetime:
start = get_epoch_seconds(timestamp_start)
end = get_epoch_seconds(timestamp_end)
return from_epoch_seconds(
start + percentage * (end - start), with_timezone=self._with_timezone
)
def get_utc_datetime(self, *args, **kwargs) -> datetime.datetime:
kwargs_ext: dict[str, t.Any] = dict(kwargs)
if self._with_timezone and ("tzinfo" not in kwargs_ext and len(args) < 8):
kwargs_ext["tzinfo"] = UTC
result = datetime.datetime(*args, **kwargs_ext)
if self._with_timezone and ("tzinfo" in kwargs or len(args) >= 8):
result = ensure_utc_timezone(result)
return result
@abc.abstractmethod
def parse_key(
self,
key_file: str | os.PathLike | None = None,
key_content: str | None = None,
passphrase: str | None = None,
) -> dict[str, t.Any]:
"""
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
Raises KeyParsingError in case of errors.
"""
@abc.abstractmethod
def sign(
self, payload64: str, protected64: str, key_data: dict[str, t.Any]
) -> dict[str, t.Any]:
pass
@abc.abstractmethod
def create_mac_key(self, alg: str, key: str) -> dict[str, t.Any]:
"""Create a MAC key."""
@abc.abstractmethod
def get_ordered_csr_identifiers(
self,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> list[tuple[str, str]]:
"""
Return a list of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
The list is deduplicated, and if a CNAME is present, it will be returned
as the first element in the result.
"""
@abc.abstractmethod
def get_csr_identifiers(
self,
csr_filename: str | os.PathLike | None = None,
csr_content: str | bytes | None = None,
) -> set[tuple[str, str]]:
"""
Return a set of requested identifiers (CN and SANs) for the CSR.
Each identifier is a pair (type, identifier), where type is either
'dns' or 'ip'.
"""
@abc.abstractmethod
def get_cert_days(
self,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
now: datetime.datetime | None = None,
) -> int:
"""
Return the days the certificate in cert_filename remains valid and -1
if the file was not found. If cert_filename contains more than one
certificate, only the first one will be considered.
If now is not specified, datetime.datetime.now() is used.
"""
@abc.abstractmethod
def create_chain_matcher(self, criterium: Criterium) -> ChainMatcher:
"""
Given a Criterium object, creates a ChainMatcher object.
"""
@abc.abstractmethod
def get_cert_information(
self,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
) -> CertificateInformation:
"""
Return some information on a X.509 certificate as a CertificateInformation object.
"""

View File

@@ -0,0 +1,395 @@
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import os
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import (
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
CertificateChain,
Criterium,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import (
Authorization,
wait_for_validation,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.io import (
write_file,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.orders import Order
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
pem_to_der,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CryptoBackend,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import (
ChainMatcher,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import (
Challenge,
)
class ACMECertificateClient:
"""
ACME v2 client class. Uses an ACME account object and a CSR to
start and validate ACME challenges and download the respective
certificates.
"""
def __init__(
self,
module: AnsibleModule,
backend: CryptoBackend,
client: ACMEClient | None = None,
account: ACMEAccount | None = None,
) -> None:
self.module = module
self.version = module.params["acme_version"]
self.csr = module.params.get("csr")
self.csr_content = module.params.get("csr_content")
if client is None:
client = ACMEClient(module, backend)
self.client = client
if account is None:
account = ACMEAccount(self.client)
self.account = account
self.order_uri = module.params.get("order_uri")
self.order_creation_error_strategy = module.params.get(
"order_creation_error_strategy", "auto"
)
self.order_creation_max_retries = module.params.get(
"order_creation_max_retries", 3
)
# Make sure account exists
dummy, account_data = self.account.setup_account(allow_creation=False)
if account_data is None:
raise ModuleFailException(msg="Account does not exist or is deactivated.")
if self.csr is not None and not os.path.exists(self.csr):
raise ModuleFailException(f"CSR {self.csr} not found")
# Extract list of identifiers from CSR
if self.csr is not None or self.csr_content is not None:
self.identifiers: list[tuple[str, str]] | None = (
self.client.backend.get_ordered_csr_identifiers(
csr_filename=self.csr, csr_content=self.csr_content
)
)
else:
self.identifiers = None
def parse_select_chain(
self, select_chain: list[dict[str, t.Any]] | None
) -> list[ChainMatcher]:
select_chain_matcher = []
if select_chain:
for criterium_idx, criterium in enumerate(select_chain):
try:
select_chain_matcher.append(
self.client.backend.create_chain_matcher(
Criterium(criterium, index=criterium_idx)
)
)
except ValueError as exc:
self.module.warn(
f"Error while parsing criterium: {exc}. Ignoring criterium."
)
return select_chain_matcher
def load_order(self) -> Order:
if not self.order_uri:
raise ModuleFailException("The order URI has not been provided")
order = Order.from_url(self.client, self.order_uri)
order.load_authorizations(self.client)
return order
def create_order(
self, replaces_cert_id: str | None = None, profile: str | None = None
) -> Order:
"""
Create a new order.
"""
if self.identifiers is None:
raise ModuleFailException("No identifiers have been provided")
order = Order.create_with_error_handling(
self.client,
self.identifiers,
error_strategy=self.order_creation_error_strategy,
error_max_retries=self.order_creation_max_retries,
replaces_cert_id=replaces_cert_id,
profile=profile,
message_callback=self.module.warn,
)
self.order_uri = order.url
order.load_authorizations(self.client)
return order
def get_challenges_data(
self, order: Order
) -> tuple[list[dict[str, t.Any]], dict[str, list[str]]]:
"""
Get challenge details.
Return a tuple of generic challenge details, and specialized DNS challenge details.
"""
data: list[dict[str, t.Any]] = []
data_dns: dict[str, list[str]] = {}
dns_challenge_type = "dns-01"
for authz in order.authorizations.values():
# Skip valid authentications: their challenges are already valid
# and do not need to be returned
if authz.status == "valid":
continue
challenge_data = authz.get_challenge_data(self.client)
data.append(
dict(
identifier=authz.identifier,
identifier_type=authz.identifier_type,
challenges=challenge_data,
)
)
dns_challenge = challenge_data.get(dns_challenge_type)
if dns_challenge:
values = data_dns.get(dns_challenge["record"])
if values is None:
values = []
data_dns[dns_challenge["record"]] = values
values.append(dns_challenge["resource_value"])
return data, data_dns
def check_that_authorizations_can_be_used(self, order: Order) -> None:
bad_authzs = []
for authz in order.authorizations.values():
if authz.status not in ("valid", "pending"):
bad_authzs.append(
f"{authz.combined_identifier} (status={authz.status!r})"
)
if bad_authzs:
bad_authzs_str = ", ".join(sorted(bad_authzs))
raise ModuleFailException(
"Some of the authorizations for the order are in a bad state, so the order"
f" can no longer be satisfied: {bad_authzs_str}",
)
def collect_invalid_authzs(self, order: Order) -> list[Authorization]:
return [
authz
for authz in order.authorizations.values()
if authz.status == "invalid"
]
def collect_pending_authzs(self, order: Order) -> list[Authorization]:
return [
authz
for authz in order.authorizations.values()
if authz.status == "pending"
]
def call_validate(
self,
pending_authzs: list[Authorization],
get_challenge: t.Callable[[Authorization], str],
wait: bool = True,
) -> list[tuple[Authorization, str, Challenge | None]]:
authzs_with_challenges_to_wait_for = []
for authz in pending_authzs:
challenge_type = get_challenge(authz)
authz.call_validate(self.client, challenge_type, wait=wait)
authzs_with_challenges_to_wait_for.append(
(authz, challenge_type, authz.find_challenge(challenge_type))
)
return authzs_with_challenges_to_wait_for
def wait_for_validation(self, authzs_to_wait_for: list[Authorization]) -> None:
wait_for_validation(authzs_to_wait_for, self.client)
def _download_alternate_chains(
self, cert: CertificateChain
) -> list[CertificateChain]:
alternate_chains = []
for alternate in cert.alternates:
try:
alt_cert = CertificateChain.download(self.client, alternate)
except ModuleFailException as e:
self.module.warn(
f"Error while downloading alternative certificate {alternate}: {e}"
)
continue
if alt_cert.cert is not None:
alternate_chains.append(alt_cert)
else:
self.module.warn(
f"Error while downloading alternative certificate {alternate}: no certificate found"
)
return alternate_chains
@t.overload
def download_certificate(
self, order: Order, *, download_all_chains: t.Literal[True] = True
) -> tuple[CertificateChain, list[CertificateChain]]: ...
@t.overload
def download_certificate(
self, order: Order, *, download_all_chains: t.Literal[False]
) -> tuple[CertificateChain, None]: ...
@t.overload
def download_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
def download_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]:
"""
Download certificate from a valid oder.
"""
if order.status != "valid":
raise ModuleFailException(
f"The order must be valid, but has state {order.status!r}!"
)
if not order.certificate_uri:
raise ModuleFailException(
f"Order's crtificate URL {order.certificate_uri!r} is empty!"
)
cert = CertificateChain.download(self.client, order.certificate_uri)
if cert.cert is None:
raise ModuleFailException(
f"Certificate at {order.certificate_uri} is empty!"
)
alternate_chains = None
if download_all_chains:
alternate_chains = self._download_alternate_chains(cert)
return cert, alternate_chains
@t.overload
def get_certificate(
self, order: Order, *, download_all_chains: t.Literal[True] = True
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
@t.overload
def get_certificate(
self, order: Order, *, download_all_chains: t.Literal[False]
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
@t.overload
def get_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]: ...
def get_certificate(
self, order: Order, *, download_all_chains: bool = True
) -> tuple[CertificateChain, list[CertificateChain] | None]:
"""
Request a new certificate and downloads it, and optionally all certificate chains.
First verifies whether all authorizations are valid; if not, aborts with an error.
"""
if self.csr is None and self.csr_content is None:
raise ModuleFailException("No CSR has been provided")
for identifier, authz in order.authorizations.items():
if authz.status != "valid":
authz.raise_error(
f'Status is {authz.status!r} and not "valid"',
module=self.module,
)
order.finalize(self.client, pem_to_der(self.csr, self.csr_content))
return self.download_certificate(order, download_all_chains=download_all_chains)
def find_matching_chain(
self,
chains: list[CertificateChain],
select_chain_matcher: t.Iterable[ChainMatcher],
) -> CertificateChain | None:
for criterium_idx, matcher in enumerate(select_chain_matcher):
for chain in chains:
if matcher.match(chain):
self.module.debug(
f"Found matching chain for criterium {criterium_idx}"
)
return chain
return None
def write_cert_chain(
self,
cert: CertificateChain,
cert_dest: str | os.PathLike | None = None,
fullchain_dest: str | os.PathLike | None = None,
chain_dest: str | os.PathLike | None = None,
) -> bool:
changed = False
if cert.cert is None:
raise ValueError("Certificate is not present")
if cert_dest and write_file(self.module, cert_dest, cert.cert.encode("utf8")):
changed = True
if fullchain_dest and write_file(
self.module,
fullchain_dest,
(cert.cert + "\n".join(cert.chain)).encode("utf8"),
):
changed = True
if chain_dest and write_file(
self.module, chain_dest, ("\n".join(cert.chain)).encode("utf8")
):
changed = True
return changed
def deactivate_authzs(self, order: Order) -> None:
"""
Deactivates all valid authz's. Does not raise exceptions.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
if len(order.authorization_uris) > len(order.authorizations):
for authz_uri in order.authorization_uris:
authz = None
try:
authz = Authorization.deactivate_url(self.client, authz_uri)
except Exception:
# ignore errors
pass
if authz is None or authz.status != "deactivated":
self.module.warn(
warning=f"Could not deactivate authz object {authz_uri}."
)
else:
for authz in order.authorizations.values():
try:
authz.deactivate(self.client)
except Exception:
# ignore errors
pass
if authz.status != "deactivated":
self.module.warn(
warning=f"Could not deactivate authz object {authz.url}."
)

View File

@@ -0,0 +1,126 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import abc
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
der_to_pem,
process_links,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
split_pem_list,
)
if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import (
ACMEClient,
)
_CertificateChain = t.TypeVar("_CertificateChain", bound="CertificateChain")
class CertificateChain:
"""
Download and parse the certificate chain.
https://tools.ietf.org/html/rfc8555#section-7.4.2
"""
def __init__(self, url: str):
self.url = url
self.cert: str | None = None
self.chain: list[str] = []
self.alternates: list[str] = []
@classmethod
def download(
cls: t.Type[_CertificateChain], client: ACMEClient, url: str
) -> _CertificateChain:
content, info = client.get_request(
url,
parse_json_result=False,
headers={"Accept": "application/pem-certificate-chain"},
)
if not content or not info["content-type"].startswith(
"application/pem-certificate-chain"
):
raise ModuleFailException(
f"Cannot download certificate chain from {url}, as content type is not application/pem-certificate-chain: {content!r} (headers: {info})"
)
result = cls(url)
# Parse data
certs = split_pem_list(content.decode("utf-8"), keep_inbetween=True)
if certs:
result.cert = certs[0]
result.chain = certs[1:]
process_links(
info, lambda link, relation: result._process_links(client, link, relation)
)
if result.cert is None:
raise ModuleFailException(
f"Failed to parse certificate chain download from {url}: {content!r} (headers: {info})"
)
return result
def _process_links(self, client: ACMEClient, link: str, relation: str) -> None:
if relation == "up":
# Process link-up headers if there was no chain in reply
if not self.chain:
chain_result, chain_info = client.get_request(
link, parse_json_result=False
)
if chain_info["status"] in [200, 201]:
self.chain.append(der_to_pem(chain_result))
elif relation == "alternate":
self.alternates.append(link)
def to_json(self) -> dict[str, bytes]:
if self.cert is None:
raise ValueError("Has no certificate")
cert = self.cert.encode("utf8")
chain = ("\n".join(self.chain)).encode("utf8")
return {
"cert": cert,
"chain": chain,
"full_chain": cert + chain,
}
class Criterium:
def __init__(self, criterium: dict[str, t.Any], index: int):
self.index = index
self.test_certificates: t.Literal["first", "last", "all"] = criterium[
"test_certificates"
]
self.subject: dict[str, t.Any] | None = criterium["subject"]
self.issuer: dict[str, t.Any] | None = criterium["issuer"]
self.subject_key_identifier: str | None = criterium["subject_key_identifier"]
self.authority_key_identifier: str | None = criterium[
"authority_key_identifier"
]
class ChainMatcher(metaclass=abc.ABCMeta):
@abc.abstractmethod
def match(self, certificate: CertificateChain) -> bool:
"""
Check whether a certificate chain (CertificateChain instance) matches.
"""

View File

@@ -0,0 +1,389 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import base64
import hashlib
import ipaddress
import json
import re
import time
import typing as t
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException,
ModuleFailException,
format_error_problem,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import (
ACMEClient,
)
def create_key_authorization(client: ACMEClient, token: str) -> str:
"""
Returns the key authorization for the given token
https://tools.ietf.org/html/rfc8555#section-8.1
"""
accountkey_json = json.dumps(
client.account_jwk, sort_keys=True, separators=(",", ":")
)
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
return f"{token}.{thumbprint}"
def combine_identifier(identifier_type: str, identifier: str) -> str:
return f"{identifier_type}:{identifier}"
def normalize_combined_identifier(identifier: str) -> str:
identifier_type, identifier = split_identifier(identifier)
# Normalize DNS names and IPs
identifier = identifier.lower()
return combine_identifier(identifier_type, identifier)
def split_identifier(identifier: str) -> tuple[str, str]:
parts = identifier.split(":", 1)
if len(parts) != 2:
raise ModuleFailException(
f'Identifier "{identifier}" is not of the form <type>:<identifier>'
)
return parts[0], parts[1]
_Challenge = t.TypeVar("_Challenge", bound="Challenge")
class Challenge:
def __init__(self, data: dict[str, t.Any], url: str) -> None:
self.data = data
self.type: str = data["type"]
self.url = url
self.status: str = data["status"]
self.token: str | None = data.get("token")
@classmethod
def from_json(
cls: t.Type[_Challenge],
client: ACMEClient,
data: dict[str, t.Any],
url: str | None = None,
) -> _Challenge:
return cls(data, url or data["url"])
def call_validate(self, client: ACMEClient) -> None:
challenge_response: dict[str, t.Any] = {}
client.send_signed_request(
self.url,
challenge_response,
error_msg="Failed to validate challenge",
expected_status_codes=[200, 202],
)
def to_json(self) -> dict[str, t.Any]:
return self.data.copy()
def get_validation_data(
self, client: ACMEClient, identifier_type: str, identifier: str
) -> dict[str, t.Any] | None:
if self.token is None:
return None
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
key_authorization = create_key_authorization(client, token)
if self.type == "http-01":
# https://tools.ietf.org/html/rfc8555#section-8.3
return {
"resource": f".well-known/acme-challenge/{token}",
"resource_value": key_authorization,
}
if self.type == "dns-01":
if identifier_type != "dns":
return None
# https://tools.ietf.org/html/rfc8555#section-8.4
resource = "_acme-challenge"
value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest())
record = f"{resource}.{identifier[2:] if identifier.startswith('*.') else identifier}"
return {
"resource": resource,
"resource_value": value,
"record": record,
}
if self.type == "tls-alpn-01":
# https://www.rfc-editor.org/rfc/rfc8737.html#section-3
if identifier_type == "ip":
# IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596)
resource = ipaddress.ip_address(identifier).reverse_pointer
if not resource.endswith("."):
resource += "."
else:
resource = identifier
b_value = base64.b64encode(
hashlib.sha256(to_bytes(key_authorization)).digest()
)
return {
"resource": resource,
"resource_original": combine_identifier(identifier_type, identifier),
"resource_value": b_value,
}
# Unknown challenge type: ignore
return None
_Authorization = t.TypeVar("_Authorization", bound="Authorization")
class Authorization:
def __init__(self, url: str) -> None:
self.url = url
self.data: dict[str, t.Any] | None = None
self.challenges: list[Challenge] = []
self.status: str | None = None
self.identifier_type: str | None = None
self.identifier: str | None = None
def _setup(self, client: ACMEClient, data: dict[str, t.Any]) -> None:
data["uri"] = self.url
self.data = data
# While 'challenges' is a required field, apparently not every CA cares
# (https://github.com/ansible-collections/community.crypto/issues/824)
if data.get("challenges"):
self.challenges = [
Challenge.from_json(client, challenge)
for challenge in data["challenges"]
]
else:
self.challenges = []
self.status = data["status"]
self.identifier = data["identifier"]["value"]
self.identifier_type = data["identifier"]["type"]
if data.get("wildcard", False):
self.identifier = f"*.{self.identifier}"
@classmethod
def from_json(
cls: t.Type[_Authorization],
client: ACMEClient,
data: dict[str, t.Any],
url: str,
) -> _Authorization:
result = cls(url)
result._setup(client, data)
return result
@classmethod
def from_url(
cls: t.Type[_Authorization], client: ACMEClient, url: str
) -> _Authorization:
result = cls(url)
result.refresh(client)
return result
@classmethod
def create(
cls: t.Type[_Authorization],
client: ACMEClient,
identifier_type: str,
identifier: str,
) -> _Authorization:
"""
Create a new authorization for the given identifier.
Return the authorization object of the new authorization
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
"""
new_authz = {
"identifier": {
"type": identifier_type,
"value": identifier,
},
}
if "newAuthz" not in client.directory.directory:
raise ACMEProtocolException(
client.module, "ACME endpoint does not support pre-authorization"
)
url = client.directory["newAuthz"]
result, info = client.send_signed_request(
url,
new_authz,
error_msg="Failed to request challenges",
expected_status_codes=[200, 201],
)
return cls.from_json(client, result, info["location"])
@property
def combined_identifier(self) -> str:
if self.identifier_type is None or self.identifier is None:
raise ValueError("Data not present")
return combine_identifier(self.identifier_type, self.identifier)
def to_json(self) -> dict[str, t.Any]:
if self.data is None:
raise ValueError("Data not present")
return self.data.copy()
def refresh(self, client: ACMEClient) -> bool:
result, dummy = client.get_request(self.url)
changed = self.data != result
self._setup(client, result)
return changed
def get_challenge_data(self, client: ACMEClient) -> dict[str, t.Any]:
"""
Returns a dict with the data for all proposed (and supported) challenges
of the given authorization.
"""
if self.identifier_type is None or self.identifier is None:
raise ValueError("Data not present")
data = {}
for challenge in self.challenges:
validation_data = challenge.get_validation_data(
client, self.identifier_type, self.identifier
)
if validation_data is not None:
data[challenge.type] = validation_data
return data
def raise_error(self, error_msg: str, module: AnsibleModule) -> t.NoReturn:
"""
Aborts with a specific error for a challenge.
"""
error_details = []
# multiple challenges could have failed at this point, gather error
# details for all of them before failing
for challenge in self.challenges:
if challenge.status == "invalid":
msg = f"Challenge {challenge.type}"
if "error" in challenge.data:
problem = format_error_problem(
challenge.data["error"],
subproblem_prefix=f"{challenge.type}.",
)
msg = f"{msg}: {problem}"
error_details.append(msg)
raise ACMEProtocolException(
module,
f"Failed to validate challenge for {self.combined_identifier}: {error_msg}. {'; '.join(error_details)}",
extras=dict(
identifier=self.combined_identifier,
authorization=self.data,
),
)
def find_challenge(self, challenge_type: str) -> Challenge | None:
for challenge in self.challenges:
if challenge_type == challenge.type:
return challenge
return None
def wait_for_validation(self, client: ACMEClient, callenge_type: str) -> bool:
while True:
self.refresh(client)
if self.status in ["valid", "invalid", "revoked"]:
break
time.sleep(2)
if self.status == "invalid":
self.raise_error('Status is "invalid"', module=client.module)
return self.status == "valid"
def call_validate(
self, client: ACMEClient, challenge_type: str, wait: bool = True
) -> bool:
"""
Validate the authorization provided in the auth dict. Returns True
when the validation was successful and False when it was not.
"""
challenge = self.find_challenge(challenge_type)
if challenge is None:
raise ModuleFailException(
f'Found no challenge of type "{challenge_type}" for identifier {self.combined_identifier}!'
)
challenge.call_validate(client)
if not wait:
return self.status == "valid"
return self.wait_for_validation(client, challenge_type)
def can_deactivate(self) -> bool:
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
return self.status in ("valid", "pending")
def deactivate(self, client: ACMEClient) -> bool | None:
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
if not self.can_deactivate():
return None
authz_deactivate = {"status": "deactivated"}
result, info = client.send_signed_request(
self.url, authz_deactivate, fail_on_error=False
)
if 200 <= info["status"] < 300 and result.get("status") == "deactivated":
self.status = "deactivated"
return True
return False
@classmethod
def deactivate_url(
cls: t.Type[_Authorization], client: ACMEClient, url: str
) -> _Authorization:
"""
Deactivates this authorization.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
"""
authz = cls(url)
authz_deactivate = {"status": "deactivated"}
result, info = client.send_signed_request(
url, authz_deactivate, fail_on_error=True
)
authz._setup(client, result)
return authz
def wait_for_validation(authzs: t.Iterable[Authorization], client: ACMEClient) -> None:
"""
Wait until a list of authz is valid. Fail if at least one of them is invalid or revoked.
"""
while authzs:
authzs_next = []
for authz in authzs:
authz.refresh(client)
if authz.status in ["valid", "invalid", "revoked"]:
if authz.status != "valid":
authz.raise_error('Status is not "valid"', module=client.module)
else:
authzs_next.append(authz)
if authzs_next:
time.sleep(2)
authzs = authzs_next

View File

@@ -0,0 +1,170 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
from http.client import responses as http_responses
from ansible.module_utils.common.text.converters import to_text
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
def format_http_status(status_code: int) -> str:
expl = http_responses.get(status_code)
if not expl:
return str(status_code)
return f"{status_code} {expl}"
def format_error_problem(problem: dict[str, t.Any], subproblem_prefix: str = "") -> str:
error_type = problem.get(
"type", "about:blank"
) # https://www.rfc-editor.org/rfc/rfc7807#section-3.1
if "title" in problem:
msg = f'Error "{problem["title"]}" ({error_type})'
else:
msg = f"Error {error_type}"
if "detail" in problem:
msg += f': "{problem["detail"]}"'
subproblems = problem.get("subproblems")
if subproblems is not None:
msg = f"{msg} Subproblems:"
for index, problem in enumerate(subproblems):
index_str = f"{subproblem_prefix}{index}"
problem_str = format_error_problem(
problem, subproblem_prefix=f"{index_str}."
)
msg = f"{msg}\n({index_str}) {problem_str}"
return msg
class ModuleFailException(Exception):
"""
If raised, module.fail_json() will be called with the given parameters after cleanup.
"""
def __init__(self, msg: str, **args: t.Any) -> None:
super(ModuleFailException, self).__init__(self, msg)
self.msg = msg
self.module_fail_args = args
def do_fail(self, module: AnsibleModule, **arguments) -> t.NoReturn:
module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments)
class ACMEProtocolException(ModuleFailException):
def __init__(
self,
module: AnsibleModule,
msg: str | None = None,
info: dict[str, t.Any] | None = None,
response=None,
content: bytes | None = None,
content_json: dict[str, t.Any] | None = None,
extras: dict[str, t.Any] | None = None,
):
# Try to get hold of content, if response is given and content is not provided
if content is None and content_json is None and response is not None:
try:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if response.closed:
raise TypeError
content = response.read()
except (AttributeError, TypeError):
if info is not None:
content = info.pop("body", None)
# Make sure that content_json is None or a dictionary
if content_json is not None and not isinstance(content_json, dict):
if content is None and isinstance(content_json, bytes):
content = content_json
content_json = None
# Try to get hold of JSON decoded content, when content is given and JSON not provided
if content_json is None and content is not None and module is not None:
try:
content_json = module.from_json(to_text(content))
except Exception:
pass
extras = extras or dict()
error_code = None
error_type = None
if msg is None:
msg = "ACME request failed"
add_msg = ""
if info is not None:
url = info["url"]
code = info["status"]
extras["http_url"] = url
extras["http_status"] = code
error_code = code
if (
code is not None
and code >= 400
and content_json is not None
and "type" in content_json
):
error_type = content_json["type"]
if "status" in content_json and content_json["status"] != code:
code_msg = f"status {content_json['status']} (HTTP status: {format_http_status(code)})"
else:
code_msg = f"status {format_http_status(code)}"
if code == -1 and info.get("msg"):
code_msg = f"error: {info['msg']}"
subproblems = content_json.pop("subproblems", None)
add_msg = f" {format_error_problem(content_json)}."
extras["problem"] = content_json
extras["subproblems"] = subproblems or []
if subproblems is not None:
add_msg = f"{add_msg} Subproblems:"
for index, problem in enumerate(subproblems):
problem = format_error_problem(
problem, subproblem_prefix=f"{index}."
)
add_msg = f"{add_msg}\n({index}) {problem}."
else:
code_msg = f"HTTP status {format_http_status(code)}"
if code == -1 and info.get("msg"):
code_msg = f"error: {info['msg']}"
if content_json is not None:
add_msg = f" The JSON error result: {content_json}"
elif content is not None:
add_msg = f" The raw error result: {to_text(content)}"
msg = f"{msg} for {url} with {code_msg}"
elif content_json is not None:
add_msg = f" The JSON result: {content_json}"
elif content is not None:
add_msg = f" The raw result: {to_text(content)}"
super(ACMEProtocolException, self).__init__(f"{msg}.{add_msg}", **extras)
self.problem: dict[str, t.Any] = {}
self.subproblems: list[dict[str, t.Any]] = []
self.error_code = error_code
self.error_type = error_type
for k, v in extras.items():
setattr(self, k, v)
class BackendException(ModuleFailException):
pass
class NetworkException(ModuleFailException):
pass
class KeyParsingError(ModuleFailException):
pass

View File

@@ -0,0 +1,97 @@
# Copyright (c) 2013, Romeo Theriault <romeot () hawaii.edu>
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import os
import shutil
import tempfile
import traceback
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ModuleFailException,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
def read_file(fn: str | os.PathLike) -> bytes:
try:
with open(fn, "rb") as f:
return f.read()
except Exception as e:
raise ModuleFailException(f'Error while reading file "{fn}": {e}')
# This function was adapted from an earlier version of https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/uri.py
def write_file(module: AnsibleModule, dest: str | os.PathLike, content: bytes) -> bool:
"""
Write content to destination file dest, only if the content
has changed.
"""
changed = False
# create a tempfile
fd, tmpsrc = tempfile.mkstemp(text=False)
f = os.fdopen(fd, "wb")
try:
f.write(content)
except Exception as err:
try:
f.close()
except Exception:
pass
os.remove(tmpsrc)
raise ModuleFailException(
f"failed to create temporary content file: {err}",
exception=traceback.format_exc(),
)
f.close()
checksum_src = None
checksum_dest = None
# raise an error if there is no tmpsrc file
if not os.path.exists(tmpsrc):
try:
os.remove(tmpsrc)
except Exception:
pass
raise ModuleFailException(f"Source {tmpsrc} does not exist")
if not os.access(tmpsrc, os.R_OK):
os.remove(tmpsrc)
raise ModuleFailException(f"Source {tmpsrc} not readable")
checksum_src = module.sha1(tmpsrc)
# check if there is no dest file
if os.path.exists(dest):
# raise an error if copy has no permission on dest
if not os.access(dest, os.W_OK):
os.remove(tmpsrc)
raise ModuleFailException(f"Destination {dest} not writable")
if not os.access(dest, os.R_OK):
os.remove(tmpsrc)
raise ModuleFailException(f"Destination {dest} not readable")
checksum_dest = module.sha1(dest)
else:
dirname = os.path.dirname(dest) or "."
if not os.access(dirname, os.W_OK):
os.remove(tmpsrc)
raise ModuleFailException(f"Destination dir {dirname} not writable")
if checksum_src != checksum_dest:
try:
shutil.copyfile(tmpsrc, dest)
changed = True
except Exception as err:
os.remove(tmpsrc)
raise ModuleFailException(
f"failed to copy {tmpsrc} to {dest}: {err}",
exception=traceback.format_exc(),
)
os.remove(tmpsrc)
return changed

View File

@@ -0,0 +1,224 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import time
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import (
Authorization,
normalize_combined_identifier,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ACMEProtocolException,
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
nopad_b64,
)
if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._acme.acme import (
ACMEClient,
)
_Order = t.TypeVar("_Order", bound="Order")
class Order:
def __init__(self, url: str) -> None:
self.url = url
self.data: dict[str, t.Any] | None = None
self.status = None
self.identifiers: list[tuple[str, str]] = []
self.replaces_cert_id = None
self.finalize_uri = None
self.certificate_uri = None
self.authorization_uris: list[str] = []
self.authorizations: dict[str, Authorization] = {}
def _setup(self, client: ACMEClient, data: dict[str, t.Any]) -> None:
self.data = data
self.status = data["status"]
self.identifiers = []
for identifier in data["identifiers"]:
self.identifiers.append((identifier["type"], identifier["value"]))
self.replaces_cert_id = data.get("replaces")
self.finalize_uri = data.get("finalize")
self.certificate_uri = data.get("certificate")
self.authorization_uris = data["authorizations"]
self.authorizations = {}
@classmethod
def from_json(
cls: t.Type[_Order], client: ACMEClient, data: dict[str, t.Any], url: str
) -> _Order:
result = cls(url)
result._setup(client, data)
return result
@classmethod
def from_url(cls: t.Type[_Order], client: ACMEClient, url: str) -> _Order:
result = cls(url)
result.refresh(client)
return result
@classmethod
def create(
cls: t.Type[_Order],
client: ACMEClient,
identifiers: list[tuple[str, str]],
replaces_cert_id: str | None = None,
profile: str | None = None,
) -> _Order:
"""
Start a new certificate order (ACME v2 protocol).
https://tools.ietf.org/html/rfc8555#section-7.4
"""
acme_identifiers = []
for identifier_type, identifier in identifiers:
acme_identifiers.append(
{
"type": identifier_type,
"value": identifier,
}
)
new_order: dict[str, t.Any] = {"identifiers": acme_identifiers}
if replaces_cert_id is not None:
new_order["replaces"] = replaces_cert_id
if profile is not None:
new_order["profile"] = profile
result, info = client.send_signed_request(
client.directory["newOrder"],
new_order,
error_msg="Failed to start new order",
expected_status_codes=[201],
)
return cls.from_json(client, result, info["location"])
@classmethod
def create_with_error_handling(
cls: t.Type[_Order],
client: ACMEClient,
identifiers: list[tuple[str, str]],
error_strategy: t.Literal[
"auto", "fail", "always", "retry_without_replaces_cert_id"
] = "auto",
error_max_retries: int = 3,
replaces_cert_id: str | None = None,
profile: str | None = None,
message_callback: t.Callable[[str], None] | None = None,
) -> _Order:
"""
error_strategy can be one of the following strings:
* ``fail``: simply fail. (Same behavior as ``Order.create()``.)
* ``retry_without_replaces_cert_id``: if ``replaces_cert_id`` is not ``None``, set it to ``None`` and retry.
The only exception is an error of type ``urn:ietf:params:acme:error:alreadyReplaced``, that indicates that
the certificate was already replaced.
* ``auto``: try to be clever. Right now this is identical to ``retry_without_replaces_cert_id``, but that can
change at any time in the future.
* ``always``: always retry until ``error_max_retries`` has been reached.
"""
tries = 0
while True:
tries += 1
try:
return cls.create(
client,
identifiers,
replaces_cert_id=replaces_cert_id,
profile=profile,
)
except ACMEProtocolException as exc:
if tries <= error_max_retries + 1 and error_strategy != "fail":
if error_strategy == "always":
continue
if (
error_strategy in ("auto", "retry_without_replaces_cert_id")
and replaces_cert_id is not None
and not (
exc.error_code == 409
and exc.error_type
== "urn:ietf:params:acme:error:alreadyReplaced"
)
):
if message_callback:
message_callback(
f"Stop passing `replaces={replaces_cert_id}` due to error {exc.error_code} {exc.error_type} when creating ACME order"
)
replaces_cert_id = None
continue
raise
def refresh(self, client: ACMEClient) -> bool:
result, dummy = client.get_request(self.url)
changed = self.data != result
self._setup(client, result)
return changed
def load_authorizations(self, client: ACMEClient) -> None:
for auth_uri in self.authorization_uris:
authz = Authorization.from_url(client, auth_uri)
self.authorizations[
normalize_combined_identifier(authz.combined_identifier)
] = authz
def wait_for_finalization(self, client: ACMEClient) -> None:
while True:
self.refresh(client)
if self.status in ["valid", "invalid", "pending", "ready"]:
break
time.sleep(2)
if self.status != "valid":
raise ACMEProtocolException(
client.module,
f'Failed to wait for order to complete; got status "{self.status}"',
content_json=self.data,
)
def finalize(self, client: ACMEClient, csr_der: bytes, wait: bool = True) -> None:
"""
Create a new certificate based on the csr.
Return the certificate object as dict
https://tools.ietf.org/html/rfc8555#section-7.4
"""
if self.finalize_uri is None:
raise ModuleFailException("finalize_uri must be set")
new_cert = {
"csr": nopad_b64(csr_der),
}
result, info = client.send_signed_request(
self.finalize_uri,
new_cert,
error_msg="Failed to finalizing order",
expected_status_codes=[200],
)
# It is not clear from the RFC whether the finalize call returns the order object or not.
# Instead of using the result, we call self.refresh(client) below.
if wait:
self.wait_for_finalization(client)
else:
self.refresh(client)
if self.status not in ["procesing", "valid", "invalid"]:
raise ACMEProtocolException(
client.module,
f'Failed to finalize order; got status "{self.status}"',
info=info,
content_json=result,
)

View File

@@ -0,0 +1,161 @@
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import base64
import datetime
import os
import re
import textwrap
import traceback
import typing as t
from urllib.parse import unquote
from ansible_collections.community.crypto.plugins.module_utils._acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
convert_int_to_bytes,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
get_now_datetime,
)
if t.TYPE_CHECKING:
from ansible_collections.community.crypto.plugins.module_utils._acme.backends import (
CertificateInformation,
CryptoBackend,
)
def nopad_b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf8").replace("=", "")
def der_to_pem(der_cert: bytes) -> str:
"""
Convert the DER format certificate in der_cert to a PEM format certificate and return it.
"""
content = "\n".join(textwrap.wrap(base64.b64encode(der_cert).decode("utf8"), 64))
return f"-----BEGIN CERTIFICATE-----\n{content}\n-----END CERTIFICATE-----\n"
def pem_to_der(
pem_filename: str | os.PathLike | None = None, pem_content: str | None = None
) -> bytes:
"""
Load PEM file, or use PEM file's content, and convert to DER.
If PEM contains multiple entities, the first entity will be used.
"""
certificate_lines = []
if pem_content is not None:
lines = pem_content.splitlines()
elif pem_filename is not None:
try:
with open(pem_filename, "rt") as f:
lines = list(f)
except Exception as err:
raise ModuleFailException(
f"cannot load PEM file {pem_filename}: {err}",
exception=traceback.format_exc(),
)
else:
raise ModuleFailException(
"One of pem_filename and pem_content must be provided"
)
header_line_count = 0
for line in lines:
if line.startswith("-----"):
header_line_count += 1
if header_line_count == 2:
# If certificate file contains other certs appended
# (like intermediate certificates), ignore these.
break
continue
certificate_lines.append(line.strip())
return base64.b64decode("".join(certificate_lines))
def process_links(
info: dict[str, t.Any], callback: t.Callable[[str, str], None]
) -> None:
"""
Process link header, calls callback for every link header with the URL and relation as options.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
"""
if "link" in info:
link = info["link"]
for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link):
callback(unquote(url), relation)
def parse_retry_after(
value: str,
relative_with_timezone: bool = True,
now: datetime.datetime | None = None,
) -> datetime.datetime:
"""
Parse the value of a Retry-After header and return a timestamp.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
"""
# First try a number of seconds
try:
delta = datetime.timedelta(seconds=int(value))
if now is None:
now = get_now_datetime(relative_with_timezone)
return now + delta
except ValueError:
pass
try:
return datetime.datetime.strptime(value, "%a, %d %b %Y %H:%M:%S GMT")
except ValueError:
pass
raise ValueError(f"Cannot parse Retry-After header value {repr(value)}")
def compute_cert_id(
backend: CryptoBackend,
cert_info: CertificateInformation | None = None,
cert_filename: str | os.PathLike | None = None,
cert_content: str | bytes | None = None,
none_if_required_information_is_missing: bool = False,
) -> str | None:
# Obtain certificate info if not provided
if cert_info is None:
cert_info = backend.get_cert_information(
cert_filename=cert_filename, cert_content=cert_content
)
# Convert Authority Key Identifier to string
if cert_info.authority_key_identifier is None:
if none_if_required_information_is_missing:
return None
raise ModuleFailException(
"Certificate has no Authority Key Identifier extension"
)
aki = (
(base64.urlsafe_b64encode(cert_info.authority_key_identifier))
.decode("ascii")
.replace("=", "")
)
# Convert serial number to string
serial_bytes = convert_int_to_bytes(cert_info.serial_number)
if ord(serial_bytes[:1]) >= 128:
serial_bytes = b"\x00" + serial_bytes
serial = (base64.urlsafe_b64encode(serial_bytes)).decode("ascii").replace("=", "")
# Compose cert ID
return f"{aki}.{serial}"