diff --git a/changelogs/fragments/236-openssh_keypair-backends.yml b/changelogs/fragments/236-openssh_keypair-backends.yml new file mode 100644 index 00000000..ea71951b --- /dev/null +++ b/changelogs/fragments/236-openssh_keypair-backends.yml @@ -0,0 +1,2 @@ +minor_changes: + - openssh_keypair - added ``backend`` parameter for selecting between the cryptography library or the OpenSSH binary for the execution of actions performed by ``openssh_keypair`` (https://github.com/ansible-collections/community.crypto/pull/236). diff --git a/plugins/module_utils/crypto/openssh.py b/plugins/module_utils/crypto/openssh.py index b41e0161..f25c8956 100644 --- a/plugins/module_utils/crypto/openssh.py +++ b/plugins/module_utils/crypto/openssh.py @@ -18,19 +18,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type - -import re - - -def parse_openssh_version(version_string): - """Parse the version output of ssh -V and return version numbers that can be compared""" - - parsed_result = re.match( - r"^.*openssh_(?P[0-9.]+)(p?[0-9]+)[^0-9]*.*$", version_string.lower() - ) - if parsed_result is not None: - version = parsed_result.group("version").strip() - else: - version = None - - return version +# This import is only to maintain backwards compatibility +from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import ( + parse_openssh_version +) diff --git a/plugins/module_utils/openssh/backends/keypair_backend.py b/plugins/module_utils/openssh/backends/keypair_backend.py new file mode 100644 index 00000000..ce4a7ef6 --- /dev/null +++ b/plugins/module_utils/openssh/backends/keypair_backend.py @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, David Kainz +# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import abc +import errno +import os +import stat +from distutils.version import LooseVersion + +from ansible.module_utils import six +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import parse_openssh_version +from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography import ( + HAS_OPENSSH_SUPPORT, + HAS_OPENSSH_PRIVATE_FORMAT, + InvalidCommentError, + InvalidPassphraseError, + InvalidPrivateKeyFileError, + OpenSSHError, + OpensshKeypair, +) + + +@six.add_metaclass(abc.ABCMeta) +class KeypairBackend(object): + + def __init__(self, module): + self.module = module + + self.path = module.params['path'] + self.force = module.params['force'] + self.size = module.params['size'] + self.type = module.params['type'] + self.comment = module.params['comment'] + self.passphrase = module.params['passphrase'] + self.regenerate = module.params['regenerate'] + + self.changed = False + self.fingerprint = '' + self.public_key = {} + + if self.regenerate == 'always': + self.force = True + + if self.type in ('rsa', 'rsa1'): + self.size = 4096 if self.size is None else self.size + if self.size < 1024: + module.fail_json(msg=('For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. ' + 'Attempting to use bit lengths under 1024 will cause the module to fail.')) + elif self.type == 'dsa': + self.size = 1024 if self.size is None else self.size + if self.size != 1024: + module.fail_json(msg=('DSA keys must be exactly 1024 bits as specified by FIPS 186-2.')) + elif self.type == 'ecdsa': + self.size = 256 if self.size is None else self.size + if self.size not in (256, 384, 521): + module.fail_json(msg=('For ECDSA keys, size determines the key length by selecting from ' + 'one of three elliptic curve sizes: 256, 384 or 521 bits. ' + 'Attempting to use bit lengths other than these three values for ' + 'ECDSA keys will cause this module to fail. ')) + elif self.type == 'ed25519': + # User input is ignored for `key size` when `key type` is ed25519 + self.size = 256 + else: + module.fail_json(msg="%s is not a valid value for key type" % self.type) + + def generate(self): + if self.force or not self.is_private_key_valid(perms_required=False): + try: + if self.exists() and not os.access(self.path, os.W_OK): + os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR) + self._generate_keypair() + self.changed = True + except (IOError, OSError) as e: + self.remove() + self.module.fail_json(msg="%s" % to_native(e)) + + self.fingerprint = self._get_current_key_properties()[2] + self.public_key = self._get_public_key() + elif not self.is_public_key_valid(perms_required=False): + pubkey = self._get_public_key() + try: + with open(self.path + ".pub", "w") as pubkey_f: + pubkey_f.write(pubkey + '\n') + os.chmod(self.path + ".pub", stat.S_IWUSR + stat.S_IRUSR + stat.S_IRGRP + stat.S_IROTH) + except (IOError, OSError): + self.module.fail_json( + msg='The public key is missing or does not match the private key. ' + 'Unable to regenerate the public key.') + self.changed = True + self.public_key = pubkey + + if self.comment: + try: + if self.exists() and not os.access(self.path, os.W_OK): + os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR) + except (IOError, OSError): + self.module.fail_json(msg='Unable to update the comment for the public key.') + self._update_comment() + + if self._permissions_changed() or self._permissions_changed(public_key=True): + self.changed = True + + def is_private_key_valid(self, perms_required=True): + if not self.exists(): + return False + + if self._check_pass_protected_or_broken_key(): + if self.regenerate in ('full_idempotence', 'always'): + return False + 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=yes`.') + + if not self._private_key_loadable(): + if os.path.isdir(self.path): + self.module.fail_json(msg='%s is a directory. Please specify a path to a file.' % self.path) + + if self.regenerate in ('full_idempotence', 'always'): + return False + 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=yes`.') + + keysize, keytype, self.fingerprint = self._get_current_key_properties() + + if self.regenerate == 'never': + return True + + if not (self.type == keytype and self.size == keysize): + if self.regenerate in ('partial_idempotence', 'full_idempotence', 'always'): + return False + 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=yes`.' + ) + + # Perms required short-circuits evaluation to prevent the side-effects of running _permissions_changed + # when check_mode is not enabled + return not (perms_required and self._permissions_changed()) + + def is_public_key_valid(self, perms_required=True): + + def _get_pubkey_content(): + if self.exists(public_key=True): + with open(self.path + ".pub", "r") as pubkey_f: + present_pubkey = pubkey_f.read().strip(' \n') + return present_pubkey + else: + return '' + + def _parse_pubkey(pubkey_content): + if pubkey_content: + parts = pubkey_content.split(' ', 2) + if len(parts) < 2: + return () + return parts[0], parts[1], '' if len(parts) <= 2 else parts[2] + return () + + def _pubkey_valid(pubkey): + if pubkey_parts and _parse_pubkey(pubkey): + return pubkey_parts[:2] == _parse_pubkey(pubkey)[:2] + return False + + def _comment_valid(): + if pubkey_parts: + return pubkey_parts[2] == self.comment + return False + + pubkey_parts = _parse_pubkey(_get_pubkey_content()) + + pubkey = self._get_public_key() + if _pubkey_valid(pubkey): + self.public_key = pubkey + else: + return False + + if self.comment and not _comment_valid(): + return False + + # Perms required short-circuits evaluation to prevent the side-effects of running _permissions_changes + # when check_mode is not enabled + return not (perms_required and self._permissions_changed(public_key=True)) + + def _permissions_changed(self, public_key=False): + file_args = self.module.load_file_common_arguments(self.module.params) + if public_key: + file_args['path'] = file_args['path'] + '.pub' + return self.module.set_fs_attributes_if_different(file_args, False) + + @property + def result(self): + return { + 'changed': self.changed, + 'size': self.size, + 'type': self.type, + 'filename': self.path, + 'fingerprint': self.fingerprint if self.fingerprint else '', + 'public_key': self.public_key, + 'comment': self.comment if self.comment else '', + } + + def remove(self): + """Remove the resource from the filesystem.""" + + try: + os.remove(self.path) + self.changed = True + except (IOError, OSError) as exc: + if exc.errno != errno.ENOENT: + self.module.fail_json(msg=to_native(exc)) + else: + pass + + if self.exists(public_key=True): + try: + os.remove(self.path + ".pub") + self.changed = True + except (IOError, OSError) as exc: + if exc.errno != errno.ENOENT: + self.module.fail_json(msg=to_native(exc)) + else: + pass + + def exists(self, public_key=False): + return os.path.exists(self.path if not public_key else self.path + ".pub") + + @abc.abstractmethod + def _generate_keypair(self): + pass + + @abc.abstractmethod + def _get_current_key_properties(self): + pass + + @abc.abstractmethod + def _get_public_key(self): + pass + + @abc.abstractmethod + def _update_comment(self): + pass + + @abc.abstractmethod + def _private_key_loadable(self): + pass + + @abc.abstractmethod + def _check_pass_protected_or_broken_key(self): + pass + + +class KeypairBackendOpensshBin(KeypairBackend): + + def __init__(self, module): + super(KeypairBackendOpensshBin, self).__init__(module) + + self.openssh_bin = module.get_bin_path('ssh-keygen') + + def _load_privatekey(self): + return self.module.run_command([self.openssh_bin, '-lf', self.path]) + + def _get_publickey_from_privatekey(self): + # -P '' is always included as an option to induce the expected standard output for + # _check_pass_protected_or_broken_key, but introduces no side-effects when used to + # output a matching public key + return self.module.run_command([self.openssh_bin, '-P', '', '-yf', self.path]) + + def _generate_keypair(self): + args = [ + self.openssh_bin, + '-q', + '-N', '', + '-b', str(self.size), + '-t', self.type, + '-f', self.path, + '-C', self.comment if self.comment else '' + ] + + # "y" must be entered in response to the "overwrite" prompt + stdin_data = 'y' if self.exists() else None + + self.module.run_command(args, data=stdin_data) + + def _get_current_key_properties(self): + rc, stdout, stderr = self._load_privatekey() + properties = stdout.split() + keysize = int(properties[0]) + fingerprint = properties[1] + keytype = properties[-1][1:-1].lower() + + return keysize, keytype, fingerprint + + def _get_public_key(self): + rc, stdout, stderr = self._get_publickey_from_privatekey() + return stdout.strip('\n') + + def _update_comment(self): + return self.module.run_command([self.openssh_bin, '-q', '-o', '-c', '-C', self.comment, '-f', self.path]) + + def _private_key_loadable(self): + rc, stdout, stderr = self._load_privatekey() + return rc == 0 + + def _check_pass_protected_or_broken_key(self): + rc, stdout, stderr = self._get_publickey_from_privatekey() + return rc == 255 or any_in(stderr, 'is not a public key file', 'incorrect passphrase', 'load failed') + + +class KeypairBackendCryptography(KeypairBackend): + + def __init__(self, module): + super(KeypairBackendCryptography, self).__init__(module) + + if module.params['private_key_format'] == 'auto': + ssh = module.get_bin_path('ssh') + if ssh: + proc = module.run_command([ssh, '-Vq']) + ssh_version = parse_openssh_version(proc[2].strip()) + else: + # Default to OpenSSH 7.8 compatibility when OpenSSH is not installed + ssh_version = "7.8" + + self.private_key_format = 'SSH' + + 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 + self.private_key_format = 'PKCS1' + + if self.private_key_format == 'SSH' and not HAS_OPENSSH_PRIVATE_FORMAT: + module.fail_json( + msg=missing_required_lib( + 'cryptography >= 3.0', + reason="to load/dump private keys in the default OpenSSH format for OpenSSH >= 7.8 " + + "or for ed25519 keys" + ) + ) + + if self.type == 'rsa1': + module.fail_json(msg="RSA1 keys are not supported by the cryptography backend") + + self.passphrase = to_bytes(self.passphrase) if self.passphrase else None + + def _load_privatekey(self): + return OpensshKeypair.load(path=self.path, passphrase=self.passphrase, no_public_key=True) + + def _generate_keypair(self): + keypair = OpensshKeypair.generate( + keytype=self.type, + size=self.size, + passphrase=self.passphrase, + comment=self.comment if self.comment else "", + ) + with open(self.path, 'w+b') as f: + f.write( + OpensshKeypair.encode_openssh_privatekey( + keypair.asymmetric_keypair, + self.private_key_format + ) + ) + # ssh-keygen defaults private key permissions to 0600 octal + os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR) + with open(self.path + '.pub', 'w+b') as f: + f.write(keypair.public_key) + # ssh-keygen defaults public key permissions to 0644 octal + os.chmod(self.path + ".pub", stat.S_IWUSR + stat.S_IRUSR + stat.S_IRGRP + stat.S_IROTH) + + def _get_current_key_properties(self): + keypair = self._load_privatekey() + + return keypair.size, keypair.key_type, keypair.fingerprint + + def _get_public_key(self): + try: + keypair = self._load_privatekey() + except OpenSSHError: + # Simulates the null output of ssh-keygen + return "" + + return to_text(keypair.public_key) + + def _update_comment(self): + keypair = self._load_privatekey() + try: + keypair.comment = self.comment + with open(self.path + ".pub", "w+b") as pubkey_file: + pubkey_file.write(keypair.public_key + b'\n') + except (InvalidCommentError, IOError, OSError) as e: + # Return values while unused currently are made to simulate the output of run_command() + return 1, "Comment could not be updated", to_native(e) + return 0, "Comment updated successfully", "" + + def _private_key_loadable(self): + try: + self._load_privatekey() + except OpenSSHError: + return False + return True + + def _check_pass_protected_or_broken_key(self): + try: + OpensshKeypair.load( + path=self.path, + passphrase=self.passphrase, + no_public_key=True, + ) + except (InvalidPrivateKeyFileError, InvalidPassphraseError): + return True + + # 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.path, + passphrase=None, + no_public_key=True, + ) + except (InvalidPrivateKeyFileError, InvalidPassphraseError): + return False + else: + return True + + return False + + +def any_in(sequence, *elements): + return any([e in sequence for e in elements]) + + +def select_backend(module, backend): + can_use_cryptography = HAS_OPENSSH_SUPPORT + 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 " + + "or cryptography >= 2.6 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 backend, KeypairBackendOpensshBin(module) + elif backend == 'cryptography': + if not can_use_cryptography: + module.fail_json(msg=missing_required_lib("cryptography >= 2.6")) + return backend, KeypairBackendCryptography(module) + else: + raise ValueError('Unsupported value for backend: {0}'.format(backend)) diff --git a/plugins/module_utils/openssh/cryptography_openssh.py b/plugins/module_utils/openssh/cryptography.py similarity index 95% rename from plugins/module_utils/openssh/cryptography_openssh.py rename to plugins/module_utils/openssh/cryptography.py index 876b0944..5bd506e3 100644 --- a/plugins/module_utils/openssh/cryptography_openssh.py +++ b/plugins/module_utils/openssh/cryptography.py @@ -18,20 +18,21 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +import os from base64 import b64encode, b64decode from distutils.version import LooseVersion from getpass import getuser from socket import gethostname try: - import cryptography as c + from cryptography import __version__ as CRYPTOGRAPHY_VERSION from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from cryptography.hazmat.backends.openssl import backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa, padding from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey - if LooseVersion(c.__version__) >= LooseVersion("3.0"): + if LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion("3.0"): HAS_OPENSSH_PRIVATE_FORMAT = True else: HAS_OPENSSH_PRIVATE_FORMAT = False @@ -78,6 +79,7 @@ try: except ImportError: HAS_OPENSSH_PRIVATE_FORMAT = False HAS_OPENSSH_SUPPORT = False + CRYPTOGRAPHY_VERSION = "0.0" _ALGORITHM_PARAMETERS = {} _TEXT_ENCODING = 'UTF-8' @@ -127,7 +129,7 @@ class InvalidSignatureError(OpenSSHError): pass -class Asymmetric_Keypair(object): +class AsymmetricKeypair(object): """Container for newly generated asymmetric key pairs or those loaded from existing files""" @classmethod @@ -261,7 +263,7 @@ class Asymmetric_Keypair(object): ) def __eq__(self, other): - if not isinstance(other, Asymmetric_Keypair): + if not isinstance(other, AsymmetricKeypair): return NotImplemented return (compare_publickeys(self.public_key, other.public_key) and @@ -344,7 +346,7 @@ class Asymmetric_Keypair(object): self.__encryption_algorithm = serialization.NoEncryption() -class OpenSSH_Keypair(object): +class OpensshKeypair(object): """Container for OpenSSH encoded asymmetric key pairs""" @classmethod @@ -360,7 +362,7 @@ class OpenSSH_Keypair(object): if comment is None: comment = "%s@%s" % (getuser(), gethostname()) - asym_keypair = Asymmetric_Keypair.generate(keytype, size, passphrase) + asym_keypair = AsymmetricKeypair.generate(keytype, size, passphrase) openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH') openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment) fingerprint = calculate_fingerprint(openssh_publickey) @@ -382,8 +384,12 @@ class OpenSSH_Keypair(object): :no_public_key: Set 'True' to only load a private key and automatically populate the matching public key """ - comment = extract_comment(path + '.pub') - asym_keypair = Asymmetric_Keypair.load(path, passphrase, 'SSH', 'SSH', no_public_key) + if no_public_key: + comment = "" + else: + comment = extract_comment(path + '.pub') + + asym_keypair = AsymmetricKeypair.load(path, passphrase, 'SSH', 'SSH', no_public_key) openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH') openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment) fingerprint = calculate_fingerprint(openssh_publickey) @@ -461,7 +467,7 @@ class OpenSSH_Keypair(object): self.__comment = comment def __eq__(self, other): - if not isinstance(other, OpenSSH_Keypair): + if not isinstance(other, OpensshKeypair): return NotImplemented return self.asymmetric_keypair == other.asymmetric_keypair and self.comment == other.comment @@ -529,7 +535,7 @@ class OpenSSH_Keypair(object): """ self.__asym_keypair.update_passphrase(passphrase) - self.__openssh_privatekey = OpenSSH_Keypair.encode_openssh_privatekey(self.__asym_keypair, 'SSH') + self.__openssh_privatekey = OpensshKeypair.encode_openssh_privatekey(self.__asym_keypair, 'SSH') def load_privatekey(path, passphrase, key_format): @@ -554,6 +560,9 @@ def load_privatekey(path, passphrase, key_format): ) ) + if not os.path.exists(path): + raise InvalidPrivateKeyFileError("No file was found at %s" % path) + try: with open(path, 'rb') as f: content = f.read() @@ -606,6 +615,9 @@ def load_publickey(path, key_format): ) ) + if not os.path.exists(path): + raise InvalidPublicKeyFileError("No file was found at %s" % path) + try: with open(path, 'rb') as f: content = f.read() @@ -658,6 +670,10 @@ def validate_comment(comment): def extract_comment(path): + + if not os.path.exists(path): + raise InvalidPublicKeyFileError("No file was found at %s" % path) + try: with open(path, 'rb') as f: fields = f.read().split(b' ', 2) @@ -665,7 +681,7 @@ def extract_comment(path): comment = fields[2].decode(_TEXT_ENCODING) else: comment = "" - except OSError as e: + except (IOError, OSError) as e: raise InvalidPublicKeyFileError(e) return comment diff --git a/plugins/module_utils/openssh/utils.py b/plugins/module_utils/openssh/utils.py new file mode 100644 index 00000000..a512bb72 --- /dev/null +++ b/plugins/module_utils/openssh/utils.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# (c) 2020, Doug Stanley +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import re + + +def parse_openssh_version(version_string): + """Parse the version output of ssh -V and return version numbers that can be compared""" + + parsed_result = re.match( + r"^.*openssh_(?P[0-9.]+)(p?[0-9]+)[^0-9]*.*$", version_string.lower() + ) + if parsed_result is not None: + version = parsed_result.group("version").strip() + else: + version = None + + return version diff --git a/plugins/modules/openssh_cert.py b/plugins/modules/openssh_cert.py index a293ba48..80a20e18 100644 --- a/plugins/modules/openssh_cert.py +++ b/plugins/modules/openssh_cert.py @@ -229,7 +229,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_native from ansible_collections.community.crypto.plugins.module_utils.crypto.support import convert_relative_to_datetime -from ansible_collections.community.crypto.plugins.module_utils.crypto.openssh import parse_openssh_version +from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import parse_openssh_version class CertificateError(Exception): diff --git a/plugins/modules/openssh_keypair.py b/plugins/modules/openssh_keypair.py index 36225681..8923d680 100644 --- a/plugins/modules/openssh_keypair.py +++ b/plugins/modules/openssh_keypair.py @@ -17,9 +17,9 @@ description: ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519) or C(ecdsa) private keys." requirements: - - "ssh-keygen" - - cryptography >= 2.6 (if using I(passphrase) and OpenSSH < 7.8 is installed) - - cryptography >= 3.0 (if using I(passphrase) and OpenSSH >= 7.8 is installed) + - ssh-keygen (if I(backend=openssh)) + - cryptography >= 2.6 (if I(backend=cryptography) and OpenSSH < 7.8 is installed) + - cryptography >= 3.0 (if I(backend=cryptography) and OpenSSH >= 7.8 is installed) options: state: description: @@ -60,11 +60,12 @@ options: description: - Passphrase used to decrypt an existing private key or encrypt a newly generated private key. - Passphrases are not supported for I(type=rsa1). + - Can only be used when I(backend=cryptography), or when I(backend=auto) and a required C(cryptography) version is installed. type: str version_added: 1.7.0 private_key_format: description: - - Used when a value for I(passphrase) is provided to select a format for the private key at the provided I(path). + - Used when a I(backend=cryptography) to select a format for the private key at the provided I(path). - The only valid option currently is C(auto) which will match the key format of the installed OpenSSH version. - For OpenSSH < 7.8 private keys will be in PKCS1 format except ed25519 keys which will be in OpenSSH format. - For OpenSSH >= 7.8 all private key types will be in the OpenSSH format. @@ -73,6 +74,17 @@ options: choices: - auto version_added: 1.7.0 + backend: + description: + - Selects between the C(cryptography) library or the OpenSSH binary C(opensshbin). + - C(auto) will default to C(opensshbin) unless the OpenSSH binary is not installed or when using I(passphrase). + type: str + default: auto + choices: + - auto + - cryptography + - opensshbin + version_added: 1.7.0 regenerate: description: - Allows to configure in which situations the module is allowed to regenerate private keys. @@ -173,390 +185,17 @@ comment: sample: test@comment ''' -import errno import os -import stat -from distutils.version import LooseVersion -from ansible.module_utils.basic import AnsibleModule, missing_required_lib -from ansible.module_utils._text import to_native, to_text, to_bytes +from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.crypto.plugins.module_utils.crypto.openssh import parse_openssh_version -from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography_openssh import ( - HAS_OPENSSH_SUPPORT, - HAS_OPENSSH_PRIVATE_FORMAT, - InvalidPassphraseError, - InvalidPrivateKeyFileError, - OpenSSH_Keypair, +from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.keypair_backend import ( + select_backend ) -class KeypairError(Exception): - pass - - -class Keypair(object): - - def __init__(self, module): - self.path = module.params['path'] - self.state = module.params['state'] - self.force = module.params['force'] - self.size = module.params['size'] - self.type = module.params['type'] - self.comment = module.params['comment'] - self.passphrase = module.params['passphrase'] - self.changed = False - self.check_mode = module.check_mode - self.privatekey = None - self.fingerprint = {} - self.public_key = {} - self.regenerate = module.params['regenerate'] - if self.regenerate == 'always': - self.force = True - - # The empty string is intentionally ignored so that dependency checks do not cause unnecessary failure - if self.passphrase: - if not HAS_OPENSSH_SUPPORT: - module.fail_json( - msg=missing_required_lib( - 'cryptography >= 2.6', - reason="to encrypt/decrypt private keys with passphrases" - ) - ) - - if module.params['private_key_format'] == 'auto': - ssh = module.get_bin_path('ssh', True) - proc = module.run_command([ssh, '-Vq']) - ssh_version = parse_openssh_version(proc[2].strip()) - - self.private_key_format = 'SSH' - - 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 - self.private_key_format = 'PKCS1' - - if self.private_key_format == 'SSH' and not HAS_OPENSSH_PRIVATE_FORMAT: - module.fail_json( - msg=missing_required_lib( - 'cryptography >= 3.0', - reason="to load/dump private keys in the default OpenSSH format for OpenSSH >= 7.8 " + - "or for ed25519 keys" - ) - ) - - if self.type == 'rsa1': - module.fail_json(msg="Passphrases are not supported for RSA1 keys.") - - self.passphrase = to_bytes(self.passphrase) - else: - self.private_key_format = None - - if self.type in ('rsa', 'rsa1'): - self.size = 4096 if self.size is None else self.size - if self.size < 1024: - module.fail_json(msg=('For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. ' - 'Attempting to use bit lengths under 1024 will cause the module to fail.')) - - if self.type == 'dsa': - self.size = 1024 if self.size is None else self.size - if self.size != 1024: - module.fail_json(msg=('DSA keys must be exactly 1024 bits as specified by FIPS 186-2.')) - - if self.type == 'ecdsa': - self.size = 256 if self.size is None else self.size - if self.size not in (256, 384, 521): - module.fail_json(msg=('For ECDSA keys, size determines the key length by selecting from ' - 'one of three elliptic curve sizes: 256, 384 or 521 bits. ' - 'Attempting to use bit lengths other than these three values for ' - 'ECDSA keys will cause this module to fail. ')) - if self.type == 'ed25519': - self.size = 256 - - def generate(self, module): - # generate a keypair - if self.force or not self.isPrivateKeyValid(module, perms_required=False): - try: - if os.path.exists(self.path) and not os.access(self.path, os.W_OK): - os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR) - self.changed = True - - if not self.passphrase: - args = [ - module.get_bin_path('ssh-keygen', True), - '-q', - '-N', '', - '-b', str(self.size), - '-t', self.type, - '-f', self.path, - ] - - if self.comment: - args.extend(['-C', self.comment]) - else: - args.extend(['-C', ""]) - - stdin_data = None - if os.path.exists(self.path): - stdin_data = 'y' - module.run_command(args, data=stdin_data) - proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path]) - self.fingerprint = proc[1].split() - pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path]) - self.public_key = pubkey[1].strip('\n') - else: - keypair = OpenSSH_Keypair.generate( - keytype=self.type, - size=self.size, - passphrase=self.passphrase, - comment=self.comment if self.comment else "", - ) - with open(self.path, 'w+b') as f: - f.write( - OpenSSH_Keypair.encode_openssh_privatekey( - keypair.asymmetric_keypair, - self.private_key_format - ) - ) - os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR) - with open(self.path + '.pub', 'w+b') as f: - f.write(keypair.public_key) - os.chmod(self.path + ".pub", stat.S_IWUSR + stat.S_IRUSR + stat.S_IRGRP + stat.S_IROTH) - self.fingerprint = [ - str(keypair.size), keypair.fingerprint, keypair.comment, "(%s)" % keypair.key_type.upper() - ] - self.public_key = to_text(b' '.join(keypair.public_key.split(b' ', 2)[:2])) - except Exception as e: - self.remove() - module.fail_json(msg="%s" % to_native(e)) - - elif not self.isPublicKeyValid(module, perms_required=False): - if not self.passphrase: - pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path]) - pubkey = pubkey[1].strip('\n') - else: - keypair = OpenSSH_Keypair.load( - path=self.path, - passphrase=self.passphrase, - no_public_key=True, - ) - pubkey = to_text(keypair.public_key) - try: - self.changed = True - with open(self.path + ".pub", "w") as pubkey_f: - pubkey_f.write(pubkey + '\n') - os.chmod(self.path + ".pub", stat.S_IWUSR + stat.S_IRUSR + stat.S_IRGRP + stat.S_IROTH) - except IOError: - module.fail_json( - msg='The public key is missing or does not match the private key. ' - 'Unable to regenerate the public key.') - self.public_key = pubkey - - if self.comment: - try: - if os.path.exists(self.path) and not os.access(self.path, os.W_OK): - os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR) - if not self.passphrase: - args = [module.get_bin_path('ssh-keygen', True), - '-q', '-o', '-c', '-C', self.comment, '-f', self.path] - module.run_command(args) - else: - keypair.comment = self.comment - with open(self.path + ".pub", "w+b") as pubkey_f: - pubkey_f.write(keypair.public_key + b'\n') - except IOError: - module.fail_json( - msg='Unable to update the comment for the public key.') - - file_args = module.load_file_common_arguments(module.params) - if module.set_fs_attributes_if_different(file_args, False): - self.changed = True - file_args['path'] = file_args['path'] + '.pub' - if module.set_fs_attributes_if_different(file_args, False): - self.changed = True - - def _check_pass_protected_or_broken_key(self, module): - key_state = module.run_command([module.get_bin_path('ssh-keygen', True), - '-P', '', '-yf', self.path], check_rc=False) - if not self.passphrase: - if key_state[0] == 255 or 'is not a public key file' in key_state[2]: - return True - if 'incorrect passphrase' in key_state[2] or 'load failed' in key_state[2]: - return True - return False - else: - try: - OpenSSH_Keypair.load( - path=self.path, - passphrase=self.passphrase, - no_public_key=True, - ) - except (InvalidPrivateKeyFileError, InvalidPassphraseError) as e: - return True - # Cryptography >= 3.0 uses a SSH key loader which does not raise an exception when a passphrase is provided - # when loading an unencrypted key so 'ssh-keygen' is used for this check - if key_state[0] == 0: - return True - return False - - def isPrivateKeyValid(self, module, perms_required=True): - - # check if the key is correct - def _check_state(): - return os.path.exists(self.path) - - if not _check_state(): - return False - - if self._check_pass_protected_or_broken_key(module): - if self.regenerate in ('full_idempotence', 'always'): - return False - module.fail_json(msg='Unable to read the key. The key is protected with a passphrase or broken.' - ' Will not proceed. To force regeneration, call the module with `generate`' - ' set to `full_idempotence` or `always`, or with `force=yes`.') - - proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path], check_rc=False) - if not proc[0] == 0: - if os.path.isdir(self.path): - module.fail_json(msg='%s is a directory. Please specify a path to a file.' % (self.path)) - - if self.regenerate in ('full_idempotence', 'always'): - return False - module.fail_json(msg='Unable to read the key. The key is protected with a passphrase or broken.' - ' Will not proceed. To force regeneration, call the module with `generate`' - ' set to `full_idempotence` or `always`, or with `force=yes`.') - - fingerprint = proc[1].split() - keysize = int(fingerprint[0]) - keytype = fingerprint[-1][1:-1].lower() - - self.fingerprint = fingerprint - - if self.regenerate == 'never': - return True - - def _check_type(): - return self.type == keytype - - def _check_size(): - return self.size == keysize - - if not (_check_type() and _check_size()): - if self.regenerate in ('partial_idempotence', 'full_idempotence', 'always'): - return False - module.fail_json(msg='Key has wrong type and/or size.' - ' Will not proceed. To force regeneration, call the module with `generate`' - ' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=yes`.') - - def _check_perms(module): - file_args = module.load_file_common_arguments(module.params) - return not module.set_fs_attributes_if_different(file_args, False) - - return not perms_required or _check_perms(module) - - def isPublicKeyValid(self, module, perms_required=True): - - def _get_pubkey_content(): - if os.path.exists(self.path + ".pub"): - with open(self.path + ".pub", "r") as pubkey_f: - present_pubkey = pubkey_f.read().strip(' \n') - return present_pubkey - else: - return False - - def _parse_pubkey(pubkey_content): - if pubkey_content: - parts = pubkey_content.split(' ', 2) - if len(parts) < 2: - return False - return parts[0], parts[1], '' if len(parts) <= 2 else parts[2] - return False - - def _pubkey_valid(pubkey): - if pubkey_parts and _parse_pubkey(pubkey): - return pubkey_parts[:2] == _parse_pubkey(pubkey)[:2] - return False - - def _comment_valid(): - if pubkey_parts: - return pubkey_parts[2] == self.comment - return False - - def _check_perms(module): - file_args = module.load_file_common_arguments(module.params) - file_args['path'] = file_args['path'] + '.pub' - return not module.set_fs_attributes_if_different(file_args, False) - - pubkey_parts = _parse_pubkey(_get_pubkey_content()) - - if not self.passphrase: - pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path]) - pubkey = pubkey[1].strip('\n') - else: - keypair = OpenSSH_Keypair.load( - path=self.path, - passphrase=self.passphrase, - no_public_key=True, - ) - pubkey = to_text(keypair.public_key) - if _pubkey_valid(pubkey): - self.public_key = pubkey - else: - return False - - if self.comment: - if not _comment_valid(): - return False - - if perms_required: - if not _check_perms(module): - return False - - return True - - def dump(self): - # return result as a dict - - """Serialize the object into a dictionary.""" - result = { - 'changed': self.changed, - 'size': self.size, - 'type': self.type, - 'filename': self.path, - # On removal this has no value - 'fingerprint': self.fingerprint[1] if self.fingerprint else '', - 'public_key': self.public_key, - 'comment': self.comment if self.comment else '', - } - - return result - - def remove(self): - """Remove the resource from the filesystem.""" - - try: - os.remove(self.path) - self.changed = True - except OSError as exc: - if exc.errno != errno.ENOENT: - raise KeypairError(exc) - else: - pass - - if os.path.exists(self.path + ".pub"): - try: - os.remove(self.path + ".pub") - self.changed = True - except OSError as exc: - if exc.errno != errno.ENOENT: - raise KeypairError(exc) - else: - pass - - def main(): - # Define Ansible Module module = AnsibleModule( argument_spec=dict( state=dict(type='str', default='present', choices=['present', 'absent']), @@ -571,13 +210,13 @@ def main(): choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always'] ), passphrase=dict(type='str', no_log=True), - private_key_format=dict(type='str', default='auto', no_log=False, choices=['auto']) + private_key_format=dict(type='str', default='auto', no_log=False, choices=['auto']), + backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'opensshbin']) ), supports_check_mode=True, add_file_common_args=True, ) - # Check if Path exists base_dir = os.path.dirname(module.params['path']) or '.' if not os.path.isdir(base_dir): module.fail_json( @@ -585,37 +224,25 @@ def main(): msg='The directory %s does not exist or the file is not a directory' % base_dir ) - keypair = Keypair(module) - - if keypair.state == 'present': + keypair = select_backend(module, module.params['backend'])[1] + if module.params['state'] == 'present': if module.check_mode: - changed = keypair.force or not keypair.isPrivateKeyValid(module) or not keypair.isPublicKeyValid(module) - result = keypair.dump() - result['changed'] = changed - module.exit_json(**result) - - try: - keypair.generate(module) - except Exception as exc: - module.fail_json(msg=to_native(exc)) + keypair.changed = any([ + keypair.force, + not keypair.is_private_key_valid(), + not keypair.is_public_key_valid() + ]) + else: + keypair.generate() else: - + # When `state=absent` no details from an existing key at the given `path` are returned in the module result if module.check_mode: - keypair.changed = os.path.exists(module.params['path']) - if keypair.changed: - keypair.fingerprint = {} - result = keypair.dump() - module.exit_json(**result) - - try: + keypair.changed = keypair.exists() + else: keypair.remove() - except Exception as exc: - module.fail_json(msg=to_native(exc)) - result = keypair.dump() - - module.exit_json(**result) + module.exit_json(**keypair.result) if __name__ == '__main__': diff --git a/tests/integration/targets/openssh_keypair/meta/main.yml b/tests/integration/targets/openssh_keypair/meta/main.yml index 5d9abb12..e7f863ef 100644 --- a/tests/integration/targets/openssh_keypair/meta/main.yml +++ b/tests/integration/targets/openssh_keypair/meta/main.yml @@ -1,3 +1,4 @@ dependencies: - setup_ssh_keygen - setup_openssl + - setup_bcrypt \ No newline at end of file diff --git a/tests/integration/targets/openssh_keypair/tasks/impl.yml b/tests/integration/targets/openssh_keypair/tasks/impl.yml new file mode 100644 index 00000000..c1d27b7c --- /dev/null +++ b/tests/integration/targets/openssh_keypair/tasks/impl.yml @@ -0,0 +1,557 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + + # Ensures no conflicts from previous test runs +- name: "({{ backend }}) Cleanup Output Directory" + ansible.builtin.file: + path: "{{ item }}" + state: absent + with_fileglob: + - "{{ output_dir }}/privatekey*" + - "{{ output_dir }}/regenerate*" + +- name: "({{ backend }}) Generate privatekey1 - standard (check mode)" + openssh_keypair: + path: '{{ output_dir }}/privatekey1' + size: 2048 + backend: "{{ backend }}" + register: privatekey1_result_check + check_mode: true + +- name: "({{ backend }}) Generate privatekey1 - standard" + openssh_keypair: + path: '{{ output_dir }}/privatekey1' + size: 2048 + backend: "{{ backend }}" + register: privatekey1_result + +- name: "({{ backend }}) Generate privatekey1 - standard (check mode idempotent)" + openssh_keypair: + path: '{{ output_dir }}/privatekey1' + size: 2048 + backend: "{{ backend }}" + register: privatekey1_idem_result_check + check_mode: true + +- name: "({{ backend }}) Generate privatekey1 - standard (idempotent)" + openssh_keypair: + path: '{{ output_dir }}/privatekey1' + size: 2048 + backend: "{{ backend }}" + register: privatekey1_idem_result + +- name: "({{ backend }}) Generate privatekey2 - default size" + openssh_keypair: + path: '{{ output_dir }}/privatekey2' + backend: "{{ backend }}" + +- name: "({{ backend }}) Generate privatekey3 - type dsa" + openssh_keypair: + path: '{{ output_dir }}/privatekey3' + type: dsa + backend: "{{ backend }}" + +- name: "({{ backend }}) Generate privatekey4 - standard" + openssh_keypair: + path: '{{ output_dir }}/privatekey4' + size: 2048 + backend: "{{ backend }}" + +- name: "({{ backend }}) Delete privatekey4 - standard" + openssh_keypair: + state: absent + path: '{{ output_dir }}/privatekey4' + backend: "{{ backend }}" + +- name: "({{ backend }}) Generate privatekey5 - standard" + openssh_keypair: + path: '{{ output_dir }}/privatekey5' + size: 2048 + backend: "{{ backend }}" + register: publickey_gen + +- name: "({{ backend }}) Generate privatekey6" + openssh_keypair: + path: '{{ output_dir }}/privatekey6' + type: rsa + size: 2048 + backend: "{{ backend }}" + +- name: "({{ backend }}) Regenerate privatekey6 via force" + openssh_keypair: + path: '{{ output_dir }}/privatekey6' + type: rsa + size: 2048 + force: yes + backend: "{{ backend }}" + register: output_regenerated_via_force + +- name: "({{ backend }}) Create broken key" + copy: + dest: '{{ item }}' + content: '' + mode: '0700' + loop: + - '{{ output_dir }}/privatekeybroken' + - '{{ output_dir }}/privatekeybroken.pub' + +- name: "({{ backend }}) Regenerate broken key - should fail" + openssh_keypair: + path: '{{ output_dir }}/privatekeybroken' + type: rsa + size: 2048 + backend: "{{ backend }}" + register: output_broken + ignore_errors: yes + +- name: "({{ backend }}) Regenerate broken key with force" + openssh_keypair: + path: '{{ output_dir }}/privatekeybroken' + type: rsa + force: yes + size: 2048 + backend: "{{ backend }}" + register: output_broken_force + +- name: "({{ backend }}) Generate read-only private key" + openssh_keypair: + path: '{{ output_dir }}/privatekeyreadonly' + type: rsa + mode: '0200' + size: 2048 + backend: "{{ backend }}" + +- name: "({{ backend }}) Regenerate read-only private key via force" + openssh_keypair: + path: '{{ output_dir }}/privatekeyreadonly' + type: rsa + force: yes + size: 2048 + backend: "{{ backend }}" + register: output_read_only + +- name: "({{ backend }}) Generate privatekey7 - standard with comment" + openssh_keypair: + path: '{{ output_dir }}/privatekey7' + comment: 'test@privatekey7' + size: 2048 + backend: "{{ backend }}" + register: privatekey7_result + +- name: "({{ backend }}) Modify privatekey7 comment" + openssh_keypair: + path: '{{ output_dir }}/privatekey7' + comment: 'test_modified@privatekey7' + size: 2048 + backend: "{{ backend }}" + register: privatekey7_modified_result + +- name: "({{ backend }}) Generate password protected key" + command: 'ssh-keygen -f {{ output_dir }}/privatekey8 -N {{ passphrase }}' + +- name: "({{ backend }}) Try to modify the password protected key - should fail" + openssh_keypair: + path: '{{ output_dir }}/privatekey8' + size: 2048 + backend: "{{ backend }}" + register: privatekey8_result + ignore_errors: yes + +- name: "({{ backend }}) Try to modify the password protected key with force=yes" + openssh_keypair: + path: '{{ output_dir }}/privatekey8' + force: yes + size: 2048 + backend: "{{ backend }}" + register: privatekey8_result_force + +- name: "({{ backend }}) Generate another password protected key" + command: 'ssh-keygen -f {{ output_dir }}/privatekey9 -N {{ passphrase }}' + +- name: "({{ backend }}) Try to modify the password protected key with passphrase" + openssh_keypair: + path: '{{ output_dir }}/privatekey9' + size: 1024 + passphrase: "{{ passphrase }}" + backend: "{{ backend }}" + register: privatekey9_modified_result + when: backend == 'cryptography' + +- name: "({{ backend }}) Generate another unprotected key" + openssh_keypair: + path: '{{ output_dir }}/privatekey10' + size: 2048 + backend: "{{ backend }}" + +- name: "({{ backend }}) Try to Modify unprotected key with passphrase" + openssh_keypair: + path: '{{ output_dir }}/privatekey10' + size: 2048 + passphrase: "{{ passphrase }}" + backend: "{{ backend }}" + ignore_errors: true + register: privatekey10_result + when: backend == 'cryptography' + + +- name: "({{ backend }}) Try to force modify the password protected key with force=true" + openssh_keypair: + path: '{{ output_dir }}/privatekey10' + size: 2048 + passphrase: "{{ passphrase }}" + force: true + backend: "{{ backend }}" + register: privatekey10_result_force + when: backend == 'cryptography' + +- name: "({{ backend }}) Ensure that ssh-keygen can read keys generated with passphrase" + command: 'ssh-keygen -yf {{ output_dir }}/privatekey10 -P {{ passphrase }}' + register: privatekey10_result_sshkeygen + when: backend == 'cryptography' + +- name: "({{ backend }}) Generate PEM encoded key with passphrase" + command: 'ssh-keygen -f {{ output_dir }}/privatekey11 -N {{ passphrase }} -m PEM' + when: backend == 'cryptography' + +- name: "({{ backend }}) Try to verify a PEM encoded key" + openssh_keypair: + path: '{{ output_dir }}/privatekey11' + size: 2048 + passphrase: "{{ passphrase }}" + backend: "{{ backend }}" + register: privatekey11_result + when: backend == 'cryptography' + +- import_tasks: ../tests/validate.yml + + +# Test regenerate option + +- name: "({{ backend }}) Regenerate - setup simple keys" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: rsa + size: 1024 + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" +- name: "({{ backend }}) Regenerate - setup password protected keys" + command: 'ssh-keygen -f {{ output_dir }}/regenerate-b-{{ item }} -N {{ passphrase }}' + loop: "{{ regenerate_values }}" + +- name: "({{ backend }}) Regenerate - setup broken keys" + copy: + dest: '{{ output_dir }}/regenerate-c-{{ item.0 }}{{ item.1 }}' + content: 'broken key' + mode: '0700' + with_nested: + - "{{ regenerate_values }}" + - [ '', '.pub' ] + - +- name: "({{ backend }}) Regenerate - setup password protected keys for passphrse test" + command: 'ssh-keygen -f {{ output_dir }}/regenerate-d-{{ item }} -N {{ passphrase }}' + loop: "{{ regenerate_values }}" + +- name: "({{ backend }}) Regenerate - modify broken keys (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-c-{{ item }}' + type: rsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" + - result.results[1] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" + - result.results[2] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - modify broken keys" + openssh_keypair: + path: '{{ output_dir }}/regenerate-c-{{ item }}' + type: rsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" + - result.results[1] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" + - result.results[2] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - modify password protected keys (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-b-{{ item }}' + type: rsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" + - result.results[1] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" + - result.results[2] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - modify password protected keys with passphrase (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-b-{{ item }}' + type: rsa + size: 1024 + passphrase: "{{ passphrase }}" + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result + when: backend == 'cryptography' + +- assert: + that: + - result.results[0] is success + - result.results[1] is failed + - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" + - result.results[2] is changed + - result.results[3] is changed + - result.results[4] is changed + when: backend == 'cryptography' + +- name: "({{ backend }}) Regenerate - modify password protected keys" + openssh_keypair: + path: '{{ output_dir }}/regenerate-b-{{ item }}' + type: rsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" + - result.results[1] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" + - result.results[2] is failed + - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - modify password protected keys with passphrase" + openssh_keypair: + path: '{{ output_dir }}/regenerate-d-{{ item }}' + type: rsa + size: 1024 + passphrase: "{{ passphrase }}" + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result + when: backend == 'cryptography' + +- assert: + that: + - result.results[0] is success + - result.results[1] is failed + - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" + - result.results[2] is changed + - result.results[3] is changed + - result.results[4] is changed + when: backend == 'cryptography' + +- name: "({{ backend }}) Regenerate - not modify regular keys (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: rsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + register: result +- assert: + that: + - result.results[0] is not changed + - result.results[1] is not changed + - result.results[2] is not changed + - result.results[3] is not changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - not modify regular keys" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: rsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + register: result +- assert: + that: + - result.results[0] is not changed + - result.results[1] is not changed + - result.results[2] is not changed + - result.results[3] is not changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - adjust key size (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: rsa + size: 1048 + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is success and result.results[0] is not changed + - result.results[1] is failed + - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" + - result.results[2] is changed + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - adjust key size" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: rsa + size: 1048 + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is success and result.results[0] is not changed + - result.results[1] is failed + - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" + - result.results[2] is changed + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - redistribute keys" + copy: + src: '{{ output_dir }}/regenerate-a-always{{ item.1 }}' + dest: '{{ output_dir }}/regenerate-a-{{ item.0 }}{{ item.1 }}' + remote_src: true + with_nested: + - "{{ regenerate_values }}" + - [ '', '.pub' ] + when: "item.0 != 'always'" + +- name: "({{ backend }}) Regenerate - adjust key type (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: dsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is success and result.results[0] is not changed + - result.results[1] is failed + - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" + - result.results[2] is changed + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - adjust key type" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: dsa + size: 1024 + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result.results[0] is success and result.results[0] is not changed + - result.results[1] is failed + - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" + - result.results[2] is changed + - result.results[3] is changed + - result.results[4] is changed + +- name: "({{ backend }}) Regenerate - redistribute keys" + copy: + src: '{{ output_dir }}/regenerate-a-always{{ item.1 }}' + dest: '{{ output_dir }}/regenerate-a-{{ item.0 }}{{ item.1 }}' + remote_src: true + with_nested: + - "{{ regenerate_values }}" + - [ '', '.pub' ] + when: "item.0 != 'always'" + +- name: "({{ backend }}) Regenerate - adjust comment (check mode)" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: dsa + size: 1024 + comment: test comment + regenerate: '{{ item }}' + backend: "{{ backend }}" + check_mode: yes + loop: "{{ regenerate_values }}" + ignore_errors: yes + register: result +- assert: + that: + - result is changed + +- name: "({{ backend }}) Regenerate - adjust comment" + openssh_keypair: + path: '{{ output_dir }}/regenerate-a-{{ item }}' + type: dsa + size: 1024 + comment: test comment + regenerate: '{{ item }}' + backend: "{{ backend }}" + loop: "{{ regenerate_values }}" + register: result +- assert: + that: + - result is changed + # for all values but 'always', the key should have not been regenerated. + # verify this by comparing fingerprints: + - result.results[0].fingerprint == result.results[1].fingerprint + - result.results[0].fingerprint == result.results[2].fingerprint + - result.results[0].fingerprint == result.results[3].fingerprint + - result.results[0].fingerprint != result.results[4].fingerprint diff --git a/tests/integration/targets/openssh_keypair/tasks/main.yml b/tests/integration/targets/openssh_keypair/tasks/main.yml index bc76bbda..b16c9235 100644 --- a/tests/integration/targets/openssh_keypair/tasks/main.yml +++ b/tests/integration/targets/openssh_keypair/tasks/main.yml @@ -4,509 +4,20 @@ # and should not be used as examples of how to write Ansible roles # #################################################################### -# Bumps up cryptography and bcrypt versions to be compatible with OpenSSH >= 7.8 -- import_tasks: ./setup_bcrypt.yml - -- name: Generate privatekey1 - standard (check mode) +- name: Backend auto-detection test openssh_keypair: - path: '{{ output_dir }}/privatekey1' - size: 2048 - register: privatekey1_result_check - check_mode: true + path: '{{ output_dir }}/auto_backend_key' + state: "{{ item }}" + loop: ['present', 'absent'] -- name: Generate privatekey1 - standard - openssh_keypair: - path: '{{ output_dir }}/privatekey1' - size: 2048 - register: privatekey1_result +- set_fact: + backends: ['opensshbin'] -- name: Generate privatekey1 - standard (check mode idempotent) - openssh_keypair: - path: '{{ output_dir }}/privatekey1' - size: 2048 - register: privatekey1_idem_result_check - check_mode: true - -- name: Generate privatekey1 - standard (idempotent) - openssh_keypair: - path: '{{ output_dir }}/privatekey1' - size: 2048 - register: privatekey1_idem_result - -- name: Generate privatekey2 - default size - openssh_keypair: - path: '{{ output_dir }}/privatekey2' - -- name: Generate privatekey3 - type dsa - openssh_keypair: - path: '{{ output_dir }}/privatekey3' - type: dsa - -- name: Generate privatekey4 - standard - openssh_keypair: - path: '{{ output_dir }}/privatekey4' - size: 2048 - -- name: Delete privatekey4 - standard - openssh_keypair: - state: absent - path: '{{ output_dir }}/privatekey4' - -- name: Generate privatekey5 - standard - openssh_keypair: - path: '{{ output_dir }}/privatekey5' - size: 2048 - register: publickey_gen - -- name: Generate privatekey6 - openssh_keypair: - path: '{{ output_dir }}/privatekey6' - type: rsa - size: 2048 - -- name: Regenerate privatekey6 via force - openssh_keypair: - path: '{{ output_dir }}/privatekey6' - type: rsa - size: 2048 - force: yes - register: output_regenerated_via_force - -- name: Create broken key - copy: - dest: '{{ item }}' - content: '' - mode: '0700' - loop: - - '{{ output_dir }}/privatekeybroken' - - '{{ output_dir }}/privatekeybroken.pub' - -- name: Regenerate broken key - should fail - openssh_keypair: - path: '{{ output_dir }}/privatekeybroken' - type: rsa - size: 2048 - register: output_broken - ignore_errors: yes - -- name: Regenerate broken key with force - openssh_keypair: - path: '{{ output_dir }}/privatekeybroken' - type: rsa - force: yes - size: 2048 - register: output_broken_force - -- name: Generate read-only private key - openssh_keypair: - path: '{{ output_dir }}/privatekeyreadonly' - type: rsa - mode: '0200' - size: 2048 - -- name: Regenerate read-only private key via force - openssh_keypair: - path: '{{ output_dir }}/privatekeyreadonly' - type: rsa - force: yes - size: 2048 - register: output_read_only - -- name: Generate privatekey7 - standard with comment - openssh_keypair: - path: '{{ output_dir }}/privatekey7' - comment: 'test@privatekey7' - size: 2048 - register: privatekey7_result - -- name: Modify privatekey7 comment - openssh_keypair: - path: '{{ output_dir }}/privatekey7' - comment: 'test_modified@privatekey7' - size: 2048 - register: privatekey7_modified_result - -- name: Generate password protected key - command: 'ssh-keygen -f {{ output_dir }}/privatekey8 -N {{ passphrase }}' - -- name: Try to modify the password protected key - should fail - openssh_keypair: - path: '{{ output_dir }}/privatekey8' - size: 2048 - register: privatekey8_result - ignore_errors: yes - -- name: Try to modify the password protected key with force=yes - openssh_keypair: - path: '{{ output_dir }}/privatekey8' - force: yes - size: 2048 - register: privatekey8_result_force - -- name: Generate another password protected key - command: 'ssh-keygen -f {{ output_dir }}/privatekey9 -N {{ passphrase }}' - -- name: Try to modify the password protected key with passphrase - openssh_keypair: - path: '{{ output_dir }}/privatekey9' - size: 1024 - passphrase: "{{ passphrase }}" - register: privatekey9_modified_result +- set_fact: + backends: "{{ backends + ['cryptography'] }}" when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') -- name: Generate another unprotected key - openssh_keypair: - path: '{{ output_dir }}/privatekey10' - size: 2048 - -- name: Try to Modify unprotected key with passphrase - openssh_keypair: - path: '{{ output_dir }}/privatekey10' - size: 2048 - passphrase: "{{ passphrase }}" - ignore_errors: true - register: privatekey10_result - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - - -- name: Try to force modify the password protected key with force=true - openssh_keypair: - path: '{{ output_dir }}/privatekey10' - size: 2048 - passphrase: "{{ passphrase }}" - force: true - register: privatekey10_result_force - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- name: Ensure that ssh-keygen can read keys generated with passphrase - command: 'ssh-keygen -yf {{ output_dir }}/privatekey10 -P {{ passphrase }}' - register: privatekey10_result_sshkeygen - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- name: Generate PEM encoded key with passphrase - command: 'ssh-keygen -f {{ output_dir }}/privatekey11 -N {{ passphrase }} -m PEM' - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- name: Try to verify a PEM encoded key - openssh_keypair: - path: '{{ output_dir }}/privatekey11' - size: 2048 - passphrase: "{{ passphrase }}" - register: privatekey11_result - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- import_tasks: ../tests/validate.yml - - -# Test regenerate option - -- name: Regenerate - setup simple keys - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: rsa - size: 1024 - loop: "{{ regenerate_values }}" -- name: Regenerate - setup password protected keys - command: 'ssh-keygen -f {{ output_dir }}/regenerate-b-{{ item }} -N {{ passphrase }}' - loop: "{{ regenerate_values }}" - -- name: Regenerate - setup broken keys - copy: - dest: '{{ output_dir }}/regenerate-c-{{ item.0 }}{{ item.1 }}' - content: 'broken key' - mode: '0700' - with_nested: - - "{{ regenerate_values }}" - - [ '', '.pub' ] - - -- name: Regenerate - setup password protected keys for passphrse test - command: 'ssh-keygen -f {{ output_dir }}/regenerate-d-{{ item }} -N {{ passphrase }}' - loop: "{{ regenerate_values }}" - -- name: Regenerate - modify broken keys (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-c-{{ item }}' - type: rsa - size: 1024 - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" - - result.results[1] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" - - result.results[2] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - modify broken keys - openssh_keypair: - path: '{{ output_dir }}/regenerate-c-{{ item }}' - type: rsa - size: 1024 - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" - - result.results[1] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" - - result.results[2] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - modify password protected keys (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-b-{{ item }}' - type: rsa - size: 1024 - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" - - result.results[1] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" - - result.results[2] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - modify password protected keys with passphrase (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-b-{{ item }}' - type: rsa - size: 1024 - passphrase: "{{ passphrase }}" - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- assert: - that: - - result.results[0] is success - - result.results[1] is failed - - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" - - result.results[2] is changed - - result.results[3] is changed - - result.results[4] is changed - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- name: Regenerate - modify password protected keys - openssh_keypair: - path: '{{ output_dir }}/regenerate-b-{{ item }}' - type: rsa - size: 1024 - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[0].msg" - - result.results[1] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[1].msg" - - result.results[2] is failed - - "'Unable to read the key. The key is protected with a passphrase or broken. Will not proceed.' in result.results[2].msg" - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - modify password protected keys with passphrase - openssh_keypair: - path: '{{ output_dir }}/regenerate-d-{{ item }}' - type: rsa - size: 1024 - passphrase: "{{ passphrase }}" - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- assert: - that: - - result.results[0] is success - - result.results[1] is failed - - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" - - result.results[2] is changed - - result.results[3] is changed - - result.results[4] is changed - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') - -- name: Regenerate - not modify regular keys (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: rsa - size: 1024 - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - register: result -- assert: - that: - - result.results[0] is not changed - - result.results[1] is not changed - - result.results[2] is not changed - - result.results[3] is not changed - - result.results[4] is changed - -- name: Regenerate - not modify regular keys - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: rsa - size: 1024 - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - register: result -- assert: - that: - - result.results[0] is not changed - - result.results[1] is not changed - - result.results[2] is not changed - - result.results[3] is not changed - - result.results[4] is changed - -- name: Regenerate - adjust key size (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: rsa - size: 1048 - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is success and result.results[0] is not changed - - result.results[1] is failed - - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" - - result.results[2] is changed - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - adjust key size - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: rsa - size: 1048 - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is success and result.results[0] is not changed - - result.results[1] is failed - - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" - - result.results[2] is changed - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - redistribute keys - copy: - src: '{{ output_dir }}/regenerate-a-always{{ item.1 }}' - dest: '{{ output_dir }}/regenerate-a-{{ item.0 }}{{ item.1 }}' - remote_src: true - with_nested: - - "{{ regenerate_values }}" - - [ '', '.pub' ] - when: "item.0 != 'always'" - -- name: Regenerate - adjust key type (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: dsa - size: 1024 - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is success and result.results[0] is not changed - - result.results[1] is failed - - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" - - result.results[2] is changed - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - adjust key type - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: dsa - size: 1024 - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result.results[0] is success and result.results[0] is not changed - - result.results[1] is failed - - "'Key has wrong type and/or size. Will not proceed.' in result.results[1].msg" - - result.results[2] is changed - - result.results[3] is changed - - result.results[4] is changed - -- name: Regenerate - redistribute keys - copy: - src: '{{ output_dir }}/regenerate-a-always{{ item.1 }}' - dest: '{{ output_dir }}/regenerate-a-{{ item.0 }}{{ item.1 }}' - remote_src: true - with_nested: - - "{{ regenerate_values }}" - - [ '', '.pub' ] - when: "item.0 != 'always'" - -- name: Regenerate - adjust comment (check mode) - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: dsa - size: 1024 - comment: test comment - regenerate: '{{ item }}' - check_mode: yes - loop: "{{ regenerate_values }}" - ignore_errors: yes - register: result -- assert: - that: - - result is changed - -- name: Regenerate - adjust comment - openssh_keypair: - path: '{{ output_dir }}/regenerate-a-{{ item }}' - type: dsa - size: 1024 - comment: test comment - regenerate: '{{ item }}' - loop: "{{ regenerate_values }}" - register: result -- assert: - that: - - result is changed - # for all values but 'always', the key should have not been regenerated. - # verify this by comparing fingerprints: - - result.results[0].fingerprint == result.results[1].fingerprint - - result.results[0].fingerprint == result.results[2].fingerprint - - result.results[0].fingerprint == result.results[3].fingerprint - - result.results[0].fingerprint != result.results[4].fingerprint +- include_tasks: ./impl.yml + loop: "{{ backends }}" + loop_control: + loop_var: backend \ No newline at end of file diff --git a/tests/integration/targets/openssh_keypair/tests/validate.yml b/tests/integration/targets/openssh_keypair/tests/validate.yml index ebdae6b0..4a61019f 100644 --- a/tests/integration/targets/openssh_keypair/tests/validate.yml +++ b/tests/integration/targets/openssh_keypair/tests/validate.yml @@ -1,9 +1,9 @@ --- -- name: Log privatekey1 return values +- name: "({{ backend }}) Log privatekey1 return values" debug: var: privatekey1_result -- name: Validate general behavior +- name: "({{ backend }}) Validate general behavior" assert: that: - privatekey1_result_check is changed @@ -12,7 +12,7 @@ - privatekey1_idem_result_check.public_key.startswith("ssh-rsa") - privatekey1_idem_result is not changed -- name: Validate privatekey1 return fingerprint +- name: "({{ backend }}) Validate privatekey1 return fingerprint" assert: that: - privatekey1_result["fingerprint"] is string @@ -20,150 +20,150 @@ # only distro old enough that it still gives md5 with no prefix when: ansible_distribution != 'CentOS' and ansible_distribution_major_version != '6' -- name: Validate privatekey1 return public_key +- name: "({{ backend }}) Validate privatekey1 return public_key" assert: that: - privatekey1_result["public_key"] is string - privatekey1_result["public_key"].startswith("ssh-rsa ") -- name: Validate privatekey1 return size value +- name: "({{ backend }}) Validate privatekey1 return size value" assert: that: - privatekey1_result["size"]|type_debug == 'int' - privatekey1_result["size"] == 2048 -- name: Validate privatekey1 return key type +- name: "({{ backend }}) Validate privatekey1 return key type" assert: that: - privatekey1_result["type"] is string - privatekey1_result["type"] == "rsa" -- name: Validate privatekey1 (test - RSA key with size 2048 bits) +- name: "({{ backend }}) Validate privatekey1 (test - RSA key with size 2048 bits)" shell: "ssh-keygen -lf {{ output_dir }}/privatekey1 | grep -o -E '^[0-9]+'" register: privatekey1 -- name: Validate privatekey1 (assert - RSA key with size 2048 bits) +- name: "({{ backend }}) Validate privatekey1 (assert - RSA key with size 2048 bits)" assert: that: - privatekey1.stdout == '2048' -- name: Validate privatekey1 idempotence +- name: "({{ backend }}) Validate privatekey1 idempotence" assert: that: - privatekey1_idem_result is not changed -- name: Validate privatekey2 (test - RSA key with default size 4096 bits) +- name: "({{ backend }}) Validate privatekey2 (test - RSA key with default size 4096 bits)" shell: "ssh-keygen -lf {{ output_dir }}/privatekey2 | grep -o -E '^[0-9]+'" register: privatekey2 -- name: Validate privatekey2 (assert - RSA key with size 4096 bits) +- name: "({{ backend }}) Validate privatekey2 (assert - RSA key with size 4096 bits)" assert: that: - privatekey2.stdout == '4096' -- name: Validate privatekey3 (test - DSA key with size 1024 bits) +- name: "({{ backend }}) Validate privatekey3 (test - DSA key with size 1024 bits)" shell: "ssh-keygen -lf {{ output_dir }}/privatekey3 | grep -o -E '^[0-9]+'" register: privatekey3 -- name: Validate privatekey3 (assert - DSA key with size 4096 bits) +- name: "({{ backend }}) Validate privatekey3 (assert - DSA key with size 4096 bits)" assert: that: - privatekey3.stdout == '1024' -- name: Validate privatekey4 (test - Ensure key has been removed) +- name: "({{ backend }}) Validate privatekey4 (test - Ensure key has been removed)" stat: path: '{{ output_dir }}/privatekey4' register: privatekey4 -- name: Validate privatekey4 (assert - Ensure key has been removed) +- name: "({{ backend }}) Validate privatekey4 (assert - Ensure key has been removed)" assert: that: - privatekey4.stat.exists == False -- name: Validate privatekey5 (assert - Public key module output equal to the public key on host) +- name: "({{ backend }}) Validate privatekey5 (assert - Public key module output equal to the public key on host)" assert: that: - "publickey_gen.public_key == lookup('file', output_dir ~ '/privatekey5.pub').strip('\n')" -- name: Verify that privatekey6 will be regenerated via force +- name: "({{ backend }}) Verify that privatekey6 will be regenerated via force" assert: that: - output_regenerated_via_force is changed -- name: Verify that broken key will cause failure +- name: "({{ backend }}) Verify that broken key will cause failure" assert: that: - output_broken is failed - "'Unable to read the key. The key is protected with a passphrase or broken.' in output_broken.msg" -- name: Verify that broken key will be regenerated if force=yes is specified +- name: "({{ backend }}) Verify that broken key will be regenerated if force=yes is specified" assert: that: - output_broken_force is changed -- name: Verify that read-only key will be regenerated +- name: "({{ backend }}) Verify that read-only key will be regenerated" assert: that: - output_read_only is changed -- name: Validate privatekey7 (assert - Public key remains the same after comment change) +- name: "({{ backend }}) Validate privatekey7 (assert - Public key remains the same after comment change)" assert: that: - privatekey7_result.public_key == privatekey7_modified_result.public_key -- name: Validate privatekey7 comment on creation +- name: "({{ backend }}) Validate privatekey7 comment on creation" assert: that: - privatekey7_result.comment == 'test@privatekey7' -- name: Validate privatekey7 comment update +- name: "({{ backend }}) Validate privatekey7 comment update" assert: that: - privatekey7_modified_result.comment == 'test_modified@privatekey7' -- name: Check that password protected key made module fail +- name: "({{ backend }}) Check that password protected key made module fail" assert: that: - privatekey8_result is failed - "'Unable to read the key. The key is protected with a passphrase or broken.' in privatekey8_result.msg" -- name: Check that password protected key was regenerated with force=yes +- name: "({{ backend }}) Check that password protected key was regenerated with force=yes" assert: that: - privatekey8_result_force is changed - block: - - name: Check that password protected key with passphrase was regenerated + - name: "({{ backend }}) Check that password protected key with passphrase was regenerated" assert: that: - privatekey9_modified_result is changed - - name: Check that modifying unprotected key with passphrase fails + - name: "({{ backend }}) Check that modifying unprotected key with passphrase fails" assert: that: - privatekey10_result is failed - "'Unable to read the key. The key is protected with a passphrase or broken.' in privatekey8_result.msg" - - name: Check that unprotected key was regenerated with force=yes and passphrase supplied + - name: "({{ backend }}) Check that unprotected key was regenerated with force=yes and passphrase supplied" assert: that: - privatekey10_result_force is changed - - name: Check that ssh-keygen output from passphrase protected key matches openssh_keypair + - name: "({{ backend }}) Check that ssh-keygen output from passphrase protected key matches openssh_keypair" assert: that: - privatekey10_result_force.public_key == privatekey10_result_sshkeygen.stdout - - name: Check that PEM encoded private keys are loaded successfully + - name: "({{ backend }}) Check that PEM encoded private keys are loaded successfully" assert: that: - privatekey11_result is success - when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') + when: backend == 'cryptography' diff --git a/tests/integration/targets/setup_bcrypt/meta/main.yml b/tests/integration/targets/setup_bcrypt/meta/main.yml new file mode 100644 index 00000000..2be15776 --- /dev/null +++ b/tests/integration/targets/setup_bcrypt/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - setup_remote_constraints + - setup_pkg_mgr diff --git a/tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml b/tests/integration/targets/setup_bcrypt/tasks/main.yml similarity index 95% rename from tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml rename to tests/integration/targets/setup_bcrypt/tasks/main.yml index 6012430d..b29981c7 100644 --- a/tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml +++ b/tests/integration/targets/setup_bcrypt/tasks/main.yml @@ -21,4 +21,4 @@ set_fact: bcrypt_version: stdout: 0.0 - when: bcrypt_version is not defined \ No newline at end of file + when: bcrypt_version is not defined diff --git a/tests/unit/plugins/module_utils/openssh/test_cryptography_openssh.py b/tests/unit/plugins/module_utils/openssh/test_cryptography.py similarity index 81% rename from tests/unit/plugins/module_utils/openssh/test_cryptography_openssh.py rename to tests/unit/plugins/module_utils/openssh/test_cryptography.py index b77b7db0..3d93949f 100644 --- a/tests/unit/plugins/module_utils/openssh/test_cryptography_openssh.py +++ b/tests/unit/plugins/module_utils/openssh/test_cryptography.py @@ -13,8 +13,8 @@ from os import remove, rmdir from socket import gethostname from tempfile import mkdtemp -from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography_openssh import ( - Asymmetric_Keypair, +from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography import ( + AsymmetricKeypair, HAS_OPENSSH_SUPPORT, InvalidCommentError, InvalidPrivateKeyFileError, @@ -22,7 +22,7 @@ from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptogra InvalidKeySizeError, InvalidKeyTypeError, InvalidPassphraseError, - OpenSSH_Keypair + OpensshKeypair ) DEFAULT_KEY_PARAMS = [ @@ -147,9 +147,9 @@ def test_default_key_params(keytype, size, passphrase, comment): } default_comment = "%s@%s" % (getuser(), gethostname()) - pair = OpenSSH_Keypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) + pair = OpensshKeypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) try: - pair = OpenSSH_Keypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) + pair = OpensshKeypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) if pair.size != default_sizes[pair.key_type] or pair.comment != default_comment: result = False except Exception as e: @@ -165,7 +165,7 @@ def test_valid_user_key_params(keytype, size, passphrase, comment): result = True try: - pair = OpenSSH_Keypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) + pair = OpensshKeypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) if pair.key_type != keytype or pair.size != size or pair.comment != comment: result = False except Exception as e: @@ -181,7 +181,7 @@ def test_invalid_user_key_params(keytype, size, passphrase, comment): result = False try: - OpenSSH_Keypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) + OpensshKeypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) except (InvalidCommentError, InvalidKeyTypeError, InvalidPassphraseError): result = True except Exception as e: @@ -197,7 +197,7 @@ def test_invalid_key_sizes(keytype, size, passphrase, comment): result = False try: - OpenSSH_Keypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) + OpensshKeypair.generate(keytype=keytype, size=size, passphrase=passphrase, comment=comment) except InvalidKeySizeError: result = True except Exception as e: @@ -210,7 +210,7 @@ def test_invalid_key_sizes(keytype, size, passphrase, comment): @pytest.mark.skipif(not HAS_OPENSSH_SUPPORT, reason="requires cryptography") def test_valid_comment_update(): - pair = OpenSSH_Keypair.generate() + pair = OpensshKeypair.generate() new_comment = "comment" try: pair.comment = new_comment @@ -225,7 +225,7 @@ def test_valid_comment_update(): def test_invalid_comment_update(): result = False - pair = OpenSSH_Keypair.generate() + pair = OpensshKeypair.generate() new_comment = [1, 2, 3] try: pair.comment = new_comment @@ -245,7 +245,7 @@ def test_valid_passphrase_update(): tmpdir = mkdtemp() keyfilename = os.path.join(tmpdir, "id_rsa") - pair1 = OpenSSH_Keypair.generate() + pair1 = OpensshKeypair.generate() pair1.update_passphrase(passphrase) with open(keyfilename, "w+b") as keyfile: @@ -254,7 +254,7 @@ def test_valid_passphrase_update(): with open(keyfilename + '.pub', "w+b") as pubkeyfile: pubkeyfile.write(pair1.public_key) - pair2 = OpenSSH_Keypair.load(path=keyfilename, passphrase=passphrase) + pair2 = OpensshKeypair.load(path=keyfilename, passphrase=passphrase) if pair1 == pair2: result = True @@ -274,7 +274,7 @@ def test_invalid_passphrase_update(): result = False passphrase = [1, 2, 3] - pair = OpenSSH_Keypair.generate() + pair = OpensshKeypair.generate() try: pair.update_passphrase(passphrase) except InvalidPassphraseError: @@ -291,7 +291,7 @@ def test_invalid_privatekey(): tmpdir = mkdtemp() keyfilename = os.path.join(tmpdir, "id_rsa") - pair = OpenSSH_Keypair.generate() + pair = OpensshKeypair.generate() with open(keyfilename, "w+b") as keyfile: keyfile.write(pair.private_key[1:]) @@ -299,7 +299,7 @@ def test_invalid_privatekey(): with open(keyfilename + '.pub', "w+b") as pubkeyfile: pubkeyfile.write(pair.public_key) - OpenSSH_Keypair.load(path=keyfilename) + OpensshKeypair.load(path=keyfilename) except InvalidPrivateKeyFileError: result = True finally: @@ -321,8 +321,8 @@ def test_mismatched_keypair(): tmpdir = mkdtemp() keyfilename = os.path.join(tmpdir, "id_rsa") - pair1 = OpenSSH_Keypair.generate() - pair2 = OpenSSH_Keypair.generate() + pair1 = OpensshKeypair.generate() + pair2 = OpensshKeypair.generate() with open(keyfilename, "w+b") as keyfile: keyfile.write(pair1.private_key) @@ -330,7 +330,7 @@ def test_mismatched_keypair(): with open(keyfilename + '.pub', "w+b") as pubkeyfile: pubkeyfile.write(pair2.public_key) - OpenSSH_Keypair.load(path=keyfilename) + OpensshKeypair.load(path=keyfilename) except InvalidPublicKeyFileError: result = True finally: @@ -346,24 +346,24 @@ def test_mismatched_keypair(): @pytest.mark.skipif(not HAS_OPENSSH_SUPPORT, reason="requires cryptography") def test_keypair_comparison(): - assert OpenSSH_Keypair.generate() != OpenSSH_Keypair.generate() - assert OpenSSH_Keypair.generate() != OpenSSH_Keypair.generate(keytype='dsa') - assert OpenSSH_Keypair.generate() != OpenSSH_Keypair.generate(keytype='ed25519') - assert OpenSSH_Keypair.generate(keytype='ed25519') != OpenSSH_Keypair.generate(keytype='ed25519') + assert OpensshKeypair.generate() != OpensshKeypair.generate() + assert OpensshKeypair.generate() != OpensshKeypair.generate(keytype='dsa') + assert OpensshKeypair.generate() != OpensshKeypair.generate(keytype='ed25519') + assert OpensshKeypair.generate(keytype='ed25519') != OpensshKeypair.generate(keytype='ed25519') try: tmpdir = mkdtemp() keys = { 'rsa': { - 'pair': OpenSSH_Keypair.generate(), + 'pair': OpensshKeypair.generate(), 'filename': os.path.join(tmpdir, "id_rsa"), }, 'dsa': { - 'pair': OpenSSH_Keypair.generate(keytype='dsa', passphrase='change_me'.encode('UTF-8')), + 'pair': OpensshKeypair.generate(keytype='dsa', passphrase='change_me'.encode('UTF-8')), 'filename': os.path.join(tmpdir, "id_dsa"), }, 'ed25519': { - 'pair': OpenSSH_Keypair.generate(keytype='ed25519'), + 'pair': OpensshKeypair.generate(keytype='ed25519'), 'filename': os.path.join(tmpdir, "id_ed25519"), } } @@ -374,9 +374,9 @@ def test_keypair_comparison(): with open(v['filename'] + '.pub', "w+b") as pubkeyfile: pubkeyfile.write(v['pair'].public_key) - assert keys['rsa']['pair'] == OpenSSH_Keypair.load(path=keys['rsa']['filename']) + assert keys['rsa']['pair'] == OpensshKeypair.load(path=keys['rsa']['filename']) - loaded_dsa_key = OpenSSH_Keypair.load(path=keys['dsa']['filename'], passphrase='change_me'.encode('UTF-8')) + loaded_dsa_key = OpensshKeypair.load(path=keys['dsa']['filename'], passphrase='change_me'.encode('UTF-8')) assert keys['dsa']['pair'] == loaded_dsa_key loaded_dsa_key.update_passphrase('change_me_again'.encode('UTF-8')) @@ -388,7 +388,7 @@ def test_keypair_comparison(): loaded_dsa_key.comment = "comment" assert keys['dsa']['pair'] != loaded_dsa_key - assert keys['ed25519']['pair'] == OpenSSH_Keypair.load(path=keys['ed25519']['filename']) + assert keys['ed25519']['pair'] == OpensshKeypair.load(path=keys['ed25519']['filename']) finally: for v in keys.values(): if os.path.exists(v['filename']): @@ -397,4 +397,4 @@ def test_keypair_comparison(): remove(v['filename'] + '.pub') if os.path.exists(tmpdir): rmdir(tmpdir) - assert OpenSSH_Keypair.generate() != [] + assert OpensshKeypair.generate() != []