mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-05-06 13:22:58 +00:00
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:
341
plugins/module_utils/_acme/account.py
Normal file
341
plugins/module_utils/_acme/account.py
Normal 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
|
||||
693
plugins/module_utils/_acme/acme.py
Normal file
693
plugins/module_utils/_acme/acme.py
Normal 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
|
||||
522
plugins/module_utils/_acme/backend_cryptography.py
Normal file
522
plugins/module_utils/_acme/backend_cryptography.py
Normal 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,
|
||||
)
|
||||
607
plugins/module_utils/_acme/backend_openssl_cli.py
Normal file
607
plugins/module_utils/_acme/backend_openssl_cli.py
Normal 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,
|
||||
)
|
||||
219
plugins/module_utils/_acme/backends.py
Normal file
219
plugins/module_utils/_acme/backends.py
Normal 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.
|
||||
"""
|
||||
395
plugins/module_utils/_acme/certificate.py
Normal file
395
plugins/module_utils/_acme/certificate.py
Normal 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}."
|
||||
)
|
||||
126
plugins/module_utils/_acme/certificates.py
Normal file
126
plugins/module_utils/_acme/certificates.py
Normal 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.
|
||||
"""
|
||||
389
plugins/module_utils/_acme/challenges.py
Normal file
389
plugins/module_utils/_acme/challenges.py
Normal 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
|
||||
170
plugins/module_utils/_acme/errors.py
Normal file
170
plugins/module_utils/_acme/errors.py
Normal 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
|
||||
97
plugins/module_utils/_acme/io.py
Normal file
97
plugins/module_utils/_acme/io.py
Normal 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
|
||||
224
plugins/module_utils/_acme/orders.py
Normal file
224
plugins/module_utils/_acme/orders.py
Normal 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,
|
||||
)
|
||||
161
plugins/module_utils/_acme/utils.py
Normal file
161
plugins/module_utils/_acme/utils.py
Normal 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}"
|
||||
Reference in New Issue
Block a user