Make all module_utils and plugin_utils private (#887)

* Add leading underscore. Remove deprecated module utils.

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

* Convert relative to absolute imports.

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

View File

@@ -0,0 +1,456 @@
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# 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 os
import stat
import traceback
import typing as t
from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
parse_openssh_version,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._openssh.certificate import (
OpensshCertificateTimeParameters,
)
from cryptography.hazmat.primitives.asymmetric.types import (
CertificateIssuerPrivateKeyTypes,
PrivateKeyTypes,
)
Param = t.ParamSpec("Param")
def restore_on_failure(
f: t.Callable[t.Concatenate[AnsibleModule, str | os.PathLike, Param], None],
) -> t.Callable[t.Concatenate[AnsibleModule, str | os.PathLike, Param], None]:
def backup_and_restore(
module: AnsibleModule, path: str | os.PathLike, *args, **kwargs
) -> None:
backup_file = module.backup_local(path) if os.path.exists(path) else None
try:
f(module, path, *args, **kwargs)
except Exception:
if backup_file is not None:
module.atomic_move(os.path.abspath(backup_file), os.path.abspath(path))
raise
else:
module.add_cleanup_file(backup_file)
return backup_and_restore
@restore_on_failure
def safe_atomic_move(
module: AnsibleModule, path: str | os.PathLike, destination: str | os.PathLike
) -> None:
module.atomic_move(os.path.abspath(path), os.path.abspath(destination))
def _restore_all_on_failure(
f: t.Callable[
t.Concatenate[
OpensshModule, list[tuple[str | os.PathLike, str | os.PathLike]], Param
],
None,
],
) -> t.Callable[
t.Concatenate[
OpensshModule, list[tuple[str | os.PathLike, str | os.PathLike]], Param
],
None,
]:
def backup_and_restore(
self: OpensshModule,
sources_and_destinations: list[tuple[str | os.PathLike, str | os.PathLike]],
*args,
**kwargs,
) -> None:
backups = [
(d, self.module.backup_local(d))
for s, d in sources_and_destinations
if os.path.exists(d)
]
try:
f(self, sources_and_destinations, *args, **kwargs)
except Exception:
for destination, backup in backups:
self.module.atomic_move(
os.path.abspath(backup), os.path.abspath(destination)
)
raise
else:
for destination, backup in backups:
self.module.add_cleanup_file(backup)
return backup_and_restore
class OpensshModule(metaclass=abc.ABCMeta):
def __init__(self, module: AnsibleModule) -> None:
self.module = module
self.changed: bool = False
self.check_mode: bool = self.module.check_mode
def execute(self) -> t.NoReturn:
try:
self._execute()
except Exception as e:
self.module.fail_json(
msg=f"unexpected error occurred: {e}",
exception=traceback.format_exc(),
)
self.module.exit_json(**self.result)
@abc.abstractmethod
def _execute(self) -> None:
pass
@property
def result(self) -> dict[str, t.Any]:
result = self._result
result["changed"] = self.changed
if self.module._diff:
result["diff"] = self.diff
return result
@property
@abc.abstractmethod
def _result(self) -> dict[str, t.Any]:
pass
@property
@abc.abstractmethod
def diff(self) -> dict[str, t.Any]:
pass
@staticmethod
def skip_if_check_mode(f: t.Callable[Param, None]) -> t.Callable[Param, None]:
def wrapper(self, *args, **kwargs) -> None:
if not self.check_mode:
f(self, *args, **kwargs)
return wrapper # type: ignore
@staticmethod
def trigger_change(f: t.Callable[Param, None]) -> t.Callable[Param, None]:
def wrapper(self, *args, **kwargs) -> None:
f(self, *args, **kwargs)
self.changed = True
return wrapper # type: ignore
def _check_if_base_dir(self, path: str | os.PathLike) -> None:
base_dir = os.path.dirname(path) or "."
if not os.path.isdir(base_dir):
self.module.fail_json(
name=base_dir,
msg=f"The directory {base_dir} does not exist or the file is not a directory",
)
def _get_ssh_version(self) -> str | None:
ssh_bin = self.module.get_bin_path("ssh")
if not ssh_bin:
return None
return parse_openssh_version(
self.module.run_command([ssh_bin, "-V", "-q"], check_rc=True)[2].strip()
)
@_restore_all_on_failure
def _safe_secure_move(
self,
sources_and_destinations: list[tuple[str | os.PathLike, str | os.PathLike]],
) -> None:
"""Moves a list of files from 'source' to 'destination' and restores 'destination' from backup upon failure.
If 'destination' does not already exist, then 'source' permissions are preserved to prevent
exposing protected data ('atomic_move' uses the 'destination' base directory mask for
permissions if 'destination' does not already exists).
"""
for source, destination in sources_and_destinations:
if os.path.exists(destination):
self.module.atomic_move(
os.path.abspath(source), os.path.abspath(destination)
)
else:
self.module.preserved_copy(source, destination)
def _update_permissions(self, path: str | os.PathLike) -> None:
file_args = self.module.load_file_common_arguments(self.module.params)
file_args["path"] = path
if not self.module.check_file_absent_if_check_mode(path):
self.changed = self.module.set_fs_attributes_if_different(
file_args, self.changed
)
else:
self.changed = True
class KeygenCommand:
def __init__(self, module: AnsibleModule) -> None:
self._bin_path = module.get_bin_path("ssh-keygen", True)
self._run_command = module.run_command
def generate_certificate(
self,
certificate_path: str,
identifier: str,
options: list[str] | None,
pkcs11_provider: str | None,
principals: list[str] | None,
serial_number: int | None,
signature_algorithm: str | None,
signing_key_path: str,
type: t.Literal["host", "user"] | None,
time_parameters: OpensshCertificateTimeParameters,
use_agent: bool,
**kwargs,
) -> tuple[int, str, str]:
args = [self._bin_path, "-s", signing_key_path, "-P", "", "-I", identifier]
if options:
for option in options:
args.extend(["-O", option])
if pkcs11_provider:
args.extend(["-D", pkcs11_provider])
if principals:
args.extend(["-n", ",".join(principals)])
if serial_number is not None:
args.extend(["-z", str(serial_number)])
if type == "host":
args.extend(["-h"])
if use_agent:
args.extend(["-U"])
if time_parameters.validity_string:
args.extend(["-V", time_parameters.validity_string])
if signature_algorithm:
args.extend(["-t", signature_algorithm])
args.append(certificate_path)
return self._run_command(args, **kwargs)
def generate_keypair(
self, private_key_path: str, size: int, type: str, comment: str | None, **kwargs
) -> tuple[int, str, str]:
args = [
self._bin_path,
"-q",
"-N",
"",
"-b",
str(size),
"-t",
type,
"-f",
private_key_path,
"-C",
comment or "",
]
# "y" must be entered in response to the "overwrite" prompt
data = "y" if os.path.exists(private_key_path) else None
return self._run_command(args, data=data, **kwargs)
def get_certificate_info(
self, certificate_path: str, **kwargs
) -> tuple[int, str, str]:
return self._run_command(
[self._bin_path, "-L", "-f", certificate_path], **kwargs
)
def get_matching_public_key(
self, private_key_path: str, **kwargs
) -> tuple[int, str, str]:
return self._run_command(
[self._bin_path, "-P", "", "-y", "-f", private_key_path], **kwargs
)
def get_private_key(self, private_key_path: str, **kwargs) -> tuple[int, str, str]:
return self._run_command(
[self._bin_path, "-l", "-f", private_key_path], **kwargs
)
def update_comment(
self,
private_key_path: str,
comment: str,
force_new_format: bool = True,
**kwargs,
) -> tuple[int, str, str]:
if os.path.exists(private_key_path) and not os.access(
private_key_path, os.W_OK
):
try:
os.chmod(private_key_path, stat.S_IWUSR + stat.S_IRUSR)
except (IOError, OSError) as e:
raise ValueError(
f"The private key at {private_key_path} is not writeable preventing a comment update ({e})"
)
command = [self._bin_path, "-q"]
if force_new_format:
command.append("-o")
command.extend(["-c", "-C", comment, "-f", private_key_path])
return self._run_command(command, **kwargs)
_PrivateKey = t.TypeVar("_PrivateKey", bound="PrivateKey")
class PrivateKey:
def __init__(
self, size: int, key_type: str, fingerprint: str, format: str = ""
) -> None:
self._size = size
self._type = key_type
self._fingerprint = fingerprint
self._format = format
@property
def size(self) -> int:
return self._size
@property
def type(self) -> str:
return self._type
@property
def fingerprint(self) -> str:
return self._fingerprint
@property
def format(self) -> str:
return self._format
@classmethod
def from_string(cls: t.Type[_PrivateKey], string: str) -> _PrivateKey:
properties = string.split()
return cls(
size=int(properties[0]),
key_type=properties[-1][1:-1].lower(),
fingerprint=properties[1],
)
def to_dict(self) -> dict[str, t.Any]:
return {
"size": self._size,
"type": self._type,
"fingerprint": self._fingerprint,
"format": self._format,
}
_PublicKey = t.TypeVar("_PublicKey", bound="PublicKey")
class PublicKey:
def __init__(self, type_string: str, data: str, comment: str | None) -> None:
self._type_string = type_string
self._data = data
self._comment = comment
def __eq__(self, other: object) -> bool:
if not isinstance(other, type(self)):
return NotImplemented
return all(
[
self._type_string == other._type_string,
self._data == other._data,
(
(self._comment == other._comment)
if self._comment is not None and other._comment is not None
else True
),
]
)
def __ne__(self, other: object) -> bool:
return not self == other
def __str__(self) -> str:
return f"{self._type_string} {self._data}"
@property
def comment(self) -> str | None:
return self._comment
@comment.setter
def comment(self, value: str | None) -> None:
self._comment = value
@property
def data(self) -> str:
return self._data
@property
def type_string(self) -> str:
return self._type_string
@classmethod
def from_string(cls: t.Type[_PublicKey], string: str) -> _PublicKey:
properties = string.strip("\n").split(" ", 2)
return cls(
type_string=properties[0],
data=properties[1],
comment=properties[2] if len(properties) > 2 else "",
)
@classmethod
def load(cls: t.Type[_PublicKey], path: str | os.PathLike) -> _PublicKey | None:
try:
with open(path, "r") as f:
properties = f.read().strip(" \n").split(" ", 2)
except (IOError, OSError):
raise
if len(properties) < 2:
return None
return cls(
type_string=properties[0],
data=properties[1],
comment="" if len(properties) <= 2 else properties[2],
)
def to_dict(self) -> dict[str, t.Any]:
return {
"comment": self._comment,
"public_key": self._data,
}
def parse_private_key_format(
path: str | os.PathLike,
) -> t.Literal["SSH", "PKCS8", "PKCS1", ""]:
with open(path, "r") as file:
header = file.readline().strip()
if header == "-----BEGIN OPENSSH PRIVATE KEY-----":
return "SSH"
elif header == "-----BEGIN PRIVATE KEY-----":
return "PKCS8"
elif header == "-----BEGIN RSA PRIVATE KEY-----":
return "PKCS1"
return ""

View File

@@ -0,0 +1,585 @@
# Copyright (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at>
# Copyright (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
# 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 os
import typing as t
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils._cryptography_dep import (
COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION,
)
from ansible_collections.community.crypto.plugins.module_utils._openssh.backends.common import (
KeygenCommand,
OpensshModule,
PrivateKey,
PublicKey,
parse_private_key_format,
)
from ansible_collections.community.crypto.plugins.module_utils._openssh.cryptography import (
CRYPTOGRAPHY_VERSION,
HAS_OPENSSH_SUPPORT,
InvalidCommentError,
InvalidPassphraseError,
InvalidPrivateKeyFileError,
OpenSSHError,
OpensshKeypair,
)
from ansible_collections.community.crypto.plugins.module_utils._openssh.utils import (
any_in,
file_mode,
secure_write,
)
from ansible_collections.community.crypto.plugins.module_utils._version import (
LooseVersion,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
from cryptography.hazmat.primitives.asymmetric.types import (
CertificateIssuerPrivateKeyTypes,
PrivateKeyTypes,
)
class KeypairBackend(OpensshModule, metaclass=abc.ABCMeta):
def __init__(self, module: AnsibleModule) -> None:
super(KeypairBackend, self).__init__(module)
self.comment: str | None = self.module.params["comment"]
self.private_key_path: str = self.module.params["path"]
self.public_key_path = self.private_key_path + ".pub"
self.regenerate: t.Literal[
"never", "fail", "partial_idempotence", "full_idempotence", "always"
] = (
self.module.params["regenerate"]
if not self.module.params["force"]
else "always"
)
self.state: t.Literal["present", "absent"] = self.module.params["state"]
self.type: t.Literal["rsa", "dsa", "rsa1", "ecdsa", "ed25519"] = (
self.module.params["type"]
)
self.size: int = self._get_size(self.module.params["size"])
self._validate_path()
self.original_private_key: PrivateKey | None = None
self.original_public_key: PublicKey | None = None
self.private_key: PrivateKey | None = None
self.public_key: PublicKey | None = None
def _get_size(self, size: int | None) -> int:
if self.type in ("rsa", "rsa1"):
result = 4096 if size is None else size
if result < 1024:
return self.module.fail_json(
msg="For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. "
+ "Attempting to use bit lengths under 1024 will cause the module to fail."
)
elif self.type == "dsa":
result = 1024 if size is None else size
if result != 1024:
return self.module.fail_json(
msg="DSA keys must be exactly 1024 bits as specified by FIPS 186-2."
)
elif self.type == "ecdsa":
result = 256 if size is None else size
if result not in (256, 384, 521):
return self.module.fail_json(
msg="For ECDSA keys, size determines the key length by selecting from one of "
+ "three elliptic curve sizes: 256, 384 or 521 bits. "
+ "Attempting to use bit lengths other than these three values for ECDSA keys will "
+ "cause this module to fail."
)
elif self.type == "ed25519":
# User input is ignored for `key size` when `key type` is ed25519
result = 256
else:
return self.module.fail_json(
msg=f"{self.type} is not a valid value for key type"
)
return result
def _validate_path(self) -> None:
self._check_if_base_dir(self.private_key_path)
if os.path.isdir(self.private_key_path):
self.module.fail_json(
msg=f"{self.private_key_path} is a directory. Please specify a path to a file."
)
def _execute(self) -> None:
self.original_private_key = self._load_private_key()
self.original_public_key = self._load_public_key()
if self.state == "present":
self._validate_key_load()
if self._should_generate():
self._generate()
elif not self._public_key_valid():
self._restore_public_key()
self.private_key = self._load_private_key()
self.public_key = self._load_public_key()
for path in (self.private_key_path, self.public_key_path):
self._update_permissions(path)
else:
if self._should_remove():
self._remove()
def _load_private_key(self) -> PrivateKey | None:
result = None
if self._private_key_exists():
try:
result = self._get_private_key()
except Exception:
pass
return result
def _private_key_exists(self) -> bool:
return os.path.exists(self.private_key_path)
@abc.abstractmethod
def _get_private_key(self) -> PrivateKey:
pass
def _load_public_key(self) -> PublicKey | None:
result = None
if self._public_key_exists():
try:
result = PublicKey.load(self.public_key_path)
except (IOError, OSError):
pass
return result
def _public_key_exists(self) -> bool:
return os.path.exists(self.public_key_path)
def _validate_key_load(self) -> None:
if (
self._private_key_exists()
and self.regenerate in ("never", "fail", "partial_idempotence")
and (self.original_private_key is None or not self._private_key_readable())
):
self.module.fail_json(
msg="Unable to read the key. The key is protected with a passphrase or broken. "
+ "Will not proceed. To force regeneration, call the module with `generate` "
+ "set to `full_idempotence` or `always`, or with `force=true`."
)
@abc.abstractmethod
def _private_key_readable(self) -> bool:
pass
def _should_generate(self) -> bool:
if self.original_private_key is None:
return True
elif self.regenerate == "never":
return False
elif self.regenerate == "fail":
if not self._private_key_valid():
self.module.fail_json(
msg="Key has wrong type and/or size. Will not proceed. "
+ "To force regeneration, call the module with `generate` set to "
+ "`partial_idempotence`, `full_idempotence` or `always`, or with `force=true`."
)
return False
elif self.regenerate in ("partial_idempotence", "full_idempotence"):
return not self._private_key_valid()
else:
return True
def _private_key_valid(self) -> bool:
if self.original_private_key is None:
return False
return all(
[
self.size == self.original_private_key.size,
self.type == self.original_private_key.type,
self._private_key_valid_backend(self.original_private_key),
]
)
@abc.abstractmethod
def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
pass
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _generate(self) -> None:
temp_private_key, temp_public_key = self._generate_temp_keypair()
try:
self._safe_secure_move(
[
(temp_private_key, self.private_key_path),
(temp_public_key, self.public_key_path),
]
)
except OSError as e:
self.module.fail_json(msg=str(e))
def _generate_temp_keypair(self) -> tuple[str, str]:
temp_private_key = os.path.join(
self.module.tmpdir, os.path.basename(self.private_key_path)
)
temp_public_key = temp_private_key + ".pub"
try:
self._generate_keypair(temp_private_key)
except (IOError, OSError) as e:
self.module.fail_json(msg=str(e))
for f in (temp_private_key, temp_public_key):
self.module.add_cleanup_file(f)
return temp_private_key, temp_public_key
@abc.abstractmethod
def _generate_keypair(self, private_key_path: str) -> None:
pass
def _public_key_valid(self) -> bool:
if self.original_public_key is None:
return False
valid_public_key = self._get_public_key()
if valid_public_key:
valid_public_key.comment = self.comment
return self.original_public_key == valid_public_key
@abc.abstractmethod
def _get_public_key(self) -> PublicKey | t.Literal[""]:
pass
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _restore_public_key(self) -> None:
try:
temp_public_key = self._create_temp_public_key(
str(self._get_public_key()) + "\n"
)
self._safe_secure_move([(temp_public_key, self.public_key_path)])
except (IOError, OSError):
self.module.fail_json(
msg="The public key is missing or does not match the private key. "
+ "Unable to regenerate the public key."
)
if self.comment:
self._update_comment()
def _create_temp_public_key(self, content: str | bytes) -> str:
temp_public_key = os.path.join(
self.module.tmpdir, os.path.basename(self.public_key_path)
)
default_permissions = 0o644
existing_permissions = file_mode(self.public_key_path)
try:
secure_write(
temp_public_key,
existing_permissions or default_permissions,
to_bytes(content),
)
except (IOError, OSError) as e:
self.module.fail_json(msg=str(e))
self.module.add_cleanup_file(temp_public_key)
return temp_public_key
@abc.abstractmethod
def _update_comment(self) -> None:
pass
def _should_remove(self) -> bool:
return self._private_key_exists() or self._public_key_exists()
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _remove(self) -> None:
try:
if self._private_key_exists():
os.remove(self.private_key_path)
if self._public_key_exists():
os.remove(self.public_key_path)
except (IOError, OSError) as e:
self.module.fail_json(msg=str(e))
@property
def _result(self) -> dict[str, t.Any]:
private_key = self.private_key or self.original_private_key
public_key = self.public_key or self.original_public_key
return {
"size": self.size,
"type": self.type,
"filename": self.private_key_path,
"fingerprint": private_key.fingerprint if private_key else "",
"public_key": str(public_key) if public_key else "",
"comment": public_key.comment if public_key else "",
}
@property
def diff(self) -> dict[str, t.Any]:
before = (
self.original_private_key.to_dict() if self.original_private_key else {}
)
before.update(
self.original_public_key.to_dict() if self.original_public_key else {}
)
after = self.private_key.to_dict() if self.private_key else {}
after.update(self.public_key.to_dict() if self.public_key else {})
return {
"before": before,
"after": after,
}
class KeypairBackendOpensshBin(KeypairBackend):
def __init__(self, module: AnsibleModule) -> None:
super(KeypairBackendOpensshBin, self).__init__(module)
if self.module.params["private_key_format"] != "auto":
self.module.fail_json(
msg="'auto' is the only valid option for 'private_key_format' when 'backend' is not 'cryptography'"
)
self.ssh_keygen = KeygenCommand(self.module)
def _generate_keypair(self, private_key_path: str) -> None:
self.ssh_keygen.generate_keypair(
private_key_path, self.size, self.type, self.comment, check_rc=True
)
def _get_private_key(self) -> PrivateKey:
rc, private_key_content, err = self.ssh_keygen.get_private_key(
self.private_key_path, check_rc=False
)
if rc != 0:
raise ValueError(err)
return PrivateKey.from_string(private_key_content)
def _get_public_key(self) -> PublicKey | t.Literal[""]:
public_key_content = self.ssh_keygen.get_matching_public_key(
self.private_key_path, check_rc=True
)[1]
return PublicKey.from_string(public_key_content)
def _private_key_readable(self) -> bool:
rc, stdout, stderr = self.ssh_keygen.get_matching_public_key(
self.private_key_path, check_rc=False
)
return not (
rc == 255
or any_in(
stderr,
"is not a public key file",
"incorrect passphrase",
"load failed",
)
)
def _update_comment(self) -> None:
try:
ssh_version = self._get_ssh_version() or "7.8"
force_new_format = (
LooseVersion("6.5") <= LooseVersion(ssh_version) < LooseVersion("7.8")
)
self.ssh_keygen.update_comment(
self.private_key_path,
self.comment or "",
force_new_format=force_new_format,
check_rc=True,
)
except (IOError, OSError) as e:
self.module.fail_json(msg=str(e))
def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
return True
class KeypairBackendCryptography(KeypairBackend):
def __init__(self, module: AnsibleModule) -> None:
super(KeypairBackendCryptography, self).__init__(module)
if self.type == "rsa1":
self.module.fail_json(
msg="RSA1 keys are not supported by the cryptography backend"
)
self.passphrase = (
to_bytes(module.params["passphrase"])
if module.params["passphrase"]
else None
)
key_format: t.Literal["auto", "pkcs1", "pkcs8", "ssh"] = module.params[
"private_key_format"
]
self.private_key_format = self._get_key_format(key_format)
def _get_key_format(
self, key_format: t.Literal["auto", "pkcs1", "pkcs8", "ssh"]
) -> t.Literal["SSH", "PKCS1", "PKCS8"]:
result: t.Literal["SSH", "PKCS1", "PKCS8"] = "SSH"
if key_format == "auto":
# Default to OpenSSH 7.8 compatibility when OpenSSH is not installed
ssh_version = self._get_ssh_version() or "7.8"
if (
LooseVersion(ssh_version) < LooseVersion("7.8")
and self.type != "ed25519"
):
# OpenSSH made SSH formatted private keys available in version 6.5,
# but still defaulted to PKCS1 format with the exception of ed25519 keys
result = "PKCS1"
else:
result = key_format.upper() # type: ignore
return result
def _generate_keypair(self, private_key_path: str) -> None:
assert self.type != "rsa1"
keypair = OpensshKeypair.generate(
keytype=self.type,
size=self.size,
passphrase=self.passphrase,
comment=self.comment or "",
)
encoded_private_key = OpensshKeypair.encode_openssh_privatekey(
keypair.asymmetric_keypair, self.private_key_format
)
secure_write(private_key_path, 0o600, encoded_private_key)
public_key_path = private_key_path + ".pub"
secure_write(public_key_path, 0o644, keypair.public_key)
def _get_private_key(self) -> PrivateKey:
keypair = OpensshKeypair.load(
path=self.private_key_path, passphrase=self.passphrase, no_public_key=True
)
return PrivateKey(
size=keypair.size,
key_type=keypair.key_type,
fingerprint=keypair.fingerprint,
format=parse_private_key_format(self.private_key_path),
)
def _get_public_key(self) -> PublicKey | t.Literal[""]:
try:
keypair = OpensshKeypair.load(
path=self.private_key_path,
passphrase=self.passphrase,
no_public_key=True,
)
except OpenSSHError:
# Simulates the null output of ssh-keygen
return ""
return PublicKey.from_string(to_text(keypair.public_key))
def _private_key_readable(self) -> bool:
try:
OpensshKeypair.load(
path=self.private_key_path,
passphrase=self.passphrase,
no_public_key=True,
)
except (InvalidPrivateKeyFileError, InvalidPassphraseError):
return False
# Cryptography >= 3.0 uses a SSH key loader which does not raise an exception when a passphrase is provided
# when loading an unencrypted key
if self.passphrase:
try:
OpensshKeypair.load(
path=self.private_key_path, passphrase=None, no_public_key=True
)
except (InvalidPrivateKeyFileError, InvalidPassphraseError):
return True
else:
return False
return True
def _update_comment(self) -> None:
keypair = OpensshKeypair.load(
path=self.private_key_path, passphrase=self.passphrase, no_public_key=True
)
try:
keypair.comment = self.comment
except InvalidCommentError as e:
self.module.fail_json(msg=str(e))
try:
temp_public_key = self._create_temp_public_key(keypair.public_key + b"\n")
self._safe_secure_move([(temp_public_key, self.public_key_path)])
except (IOError, OSError) as e:
self.module.fail_json(msg=str(e))
def _private_key_valid_backend(self, original_private_key: PrivateKey) -> bool:
# avoids breaking behavior and prevents
# automatic conversions with OpenSSH upgrades
if self.module.params["private_key_format"] == "auto":
return True
return self.private_key_format == original_private_key.format
def select_backend(
module: AnsibleModule, backend: t.Literal["auto", "opensshbin", "cryptography"]
) -> KeypairBackend:
can_use_cryptography = HAS_OPENSSH_SUPPORT and LooseVersion(
CRYPTOGRAPHY_VERSION
) >= LooseVersion(COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION)
can_use_opensshbin = bool(module.get_bin_path("ssh-keygen"))
if backend == "auto":
if can_use_opensshbin and not module.params["passphrase"]:
backend = "opensshbin"
elif can_use_cryptography:
backend = "cryptography"
else:
module.fail_json(
msg=(
"Cannot find either the OpenSSH binary in the PATH "
f"or cryptography >= {COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION} installed on this system"
)
)
if backend == "opensshbin":
if not can_use_opensshbin:
module.fail_json(msg="Cannot find the OpenSSH binary in the PATH")
return KeypairBackendOpensshBin(module)
if backend == "cryptography":
if not can_use_cryptography:
module.fail_json(
msg=missing_required_lib(
f"cryptography >= {COLLECTION_MINIMUM_CRYPTOGRAPHY_VERSION}"
)
)
return KeypairBackendCryptography(module)
raise ValueError(f"Unsupported value for backend: {backend}")