mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-03-26 21:33:25 +00:00
547 lines
20 KiB
Python
547 lines
20 KiB
Python
# 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_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 # pragma: no cover
|
|
|
|
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
|
|
|
|
from ansible_collections.community.crypto.plugins.module_utils._acme.certificates import ( # pragma: no cover
|
|
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:
|
|
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_text(v))
|
|
for k, v in parse_name_field(
|
|
criterium.subject, name_field_name="subject"
|
|
)
|
|
]
|
|
if criterium.issuer:
|
|
self.issuer = [
|
|
(cryptography_name_to_oid(k), to_text(v))
|
|
for k, v in parse_name_field(criterium.issuer, name_field_name="issuer")
|
|
]
|
|
self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier(
|
|
key_identifier=criterium.subject_key_identifier,
|
|
name="subject_key_identifier",
|
|
criterium_idx=criterium.index,
|
|
module=module,
|
|
)
|
|
self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier(
|
|
key_identifier=criterium.authority_key_identifier,
|
|
name="authority_key_identifier",
|
|
criterium_idx=criterium.index,
|
|
module=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_text(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=x509.subject, match_subject=self.subject
|
|
):
|
|
matches = False
|
|
if not self._match_subject(
|
|
x509_subject=x509.issuer, match_subject=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().__init__(module=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}") from 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",
|
|
}
|
|
if 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,
|
|
}
|
|
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, digits=2 * key_data["point_size"])
|
|
ss = convert_int_to_hex(s, digits=2 * key_data["point_size"])
|
|
signature = binascii.unhexlify(rr) + binascii.unhexlify(ss)
|
|
else:
|
|
raise AssertionError("Can never be reached") # pragma: no cover
|
|
|
|
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}") from e
|
|
raise BackendException(
|
|
f"Cannot parse certificate {cert_filename}: {e}"
|
|
) from 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=criterium, module=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}") from e
|
|
raise BackendException(
|
|
f"Cannot parse certificate {cert_filename}: {e}"
|
|
) from 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,
|
|
)
|
|
|
|
|
|
__all__ = (
|
|
"CRYPTOGRAPHY_MINIMAL_VERSION",
|
|
"CRYPTOGRAPHY_ERROR",
|
|
"CRYPTOGRAPHY_VERSION",
|
|
"CRYPTOGRAPHY_ERROR",
|
|
"CryptographyBackend",
|
|
)
|