From 6100d9b4df0657a4c8e5876470acabd123829dc5 Mon Sep 17 00:00:00 2001 From: Ajpantuso Date: Mon, 10 May 2021 08:47:01 -0400 Subject: [PATCH] openssh_keypair: Adding passphrase parameter (#225) * Integrating openssh module utils with openssh_keypair * Added explicit PEM formatting for OpenSSH < 7.8 * Adding changelog fragment * Adding OpenSSL/cryptography dependency for integration tests * Adding private_key_format option and removing forced cryptography update for CI * Fixed version check for bcrypt and key_format option name * Setting no_log=False for private_key_format * Docs correction and simplification of control flow for private_key_format --- .../225-openssh-keypair-passphrase.yml | 2 + .../openssh/cryptography_openssh.py | 91 +++++--- plugins/modules/openssh_keypair.py | 208 ++++++++++++++---- .../targets/openssh_keypair/meta/main.yml | 1 + .../targets/openssh_keypair/tasks/main.yml | 109 ++++++++- .../openssh_keypair/tasks/setup_bcrypt.yml | 24 ++ .../openssh_keypair/tests/validate.yml | 28 +++ .../targets/openssh_keypair/vars/main.yml | 1 + 8 files changed, 395 insertions(+), 69 deletions(-) create mode 100644 changelogs/fragments/225-openssh-keypair-passphrase.yml create mode 100644 tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml diff --git a/changelogs/fragments/225-openssh-keypair-passphrase.yml b/changelogs/fragments/225-openssh-keypair-passphrase.yml new file mode 100644 index 00000000..34147861 --- /dev/null +++ b/changelogs/fragments/225-openssh-keypair-passphrase.yml @@ -0,0 +1,2 @@ +minor_changes: + - openssh_keypair - Added ``passphrase`` paramter to openssh_keypair for encrypting/decrtypting OpenSSH private keys (https://github.com/ansible-collections/community.crypto/pull/225). diff --git a/plugins/module_utils/openssh/cryptography_openssh.py b/plugins/module_utils/openssh/cryptography_openssh.py index ebd1cbdf..876b0944 100644 --- a/plugins/module_utils/openssh/cryptography_openssh.py +++ b/plugins/module_utils/openssh/cryptography_openssh.py @@ -19,24 +19,23 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type from base64 import b64encode, b64decode +from distutils.version import LooseVersion from getpass import getuser from socket import gethostname -from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( - HAS_CRYPTOGRAPHY, - CRYPTOGRAPHY_HAS_ED25519, -) - try: + import cryptography as c 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 -except ImportError: - pass -if HAS_CRYPTOGRAPHY and CRYPTOGRAPHY_HAS_ED25519: + if LooseVersion(c.__version__) >= LooseVersion("3.0"): + HAS_OPENSSH_PRIVATE_FORMAT = True + else: + HAS_OPENSSH_PRIVATE_FORMAT = False + HAS_OPENSSH_SUPPORT = True _ALGORITHM_PARAMETERS = { @@ -76,7 +75,8 @@ if HAS_CRYPTOGRAPHY and CRYPTOGRAPHY_HAS_ED25519: } } } -else: +except ImportError: + HAS_OPENSSH_PRIVATE_FORMAT = False HAS_OPENSSH_SUPPORT = False _ALGORITHM_PARAMETERS = {} @@ -192,12 +192,14 @@ class Asymmetric_Keypair(object): ) @classmethod - def load(cls, path, passphrase=None, key_format='PEM'): + def load(cls, path, passphrase=None, private_key_format='PEM', public_key_format='PEM', no_public_key=False): """Returns an Asymmetric_Keypair object loaded from the supplied file path :path: A path to an existing private key to be loaded :passphrase: Secret of type bytes used to decrypt the private key being loaded - :key_format: Format of key files to be loaded + :private_key_format: Format of private key to be loaded + :public_key_format: Format of public key to be loaded + :no_public_key: Set 'True' to only load a private key and automatically populate the matching public key """ if passphrase: @@ -205,8 +207,11 @@ class Asymmetric_Keypair(object): else: encryption_algorithm = serialization.NoEncryption() - privatekey = load_privatekey(path, passphrase, key_format) - publickey = load_publickey(path + '.pub', key_format) + privatekey = load_privatekey(path, passphrase, private_key_format) + if no_public_key: + publickey = privatekey.public_key() + else: + publickey = load_publickey(path + '.pub', public_key_format) # Ed25519 keys are always of size 256 and do not have a key_size attribute if isinstance(privatekey, Ed25519PrivateKey): @@ -352,11 +357,11 @@ class OpenSSH_Keypair(object): :comment: Comment for a newly generated OpenSSH public key """ - if not comment: + if comment is None: comment = "%s@%s" % (getuser(), gethostname()) asym_keypair = Asymmetric_Keypair.generate(keytype, size, passphrase) - openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair) + openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH') openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment) fingerprint = calculate_fingerprint(openssh_publickey) @@ -365,20 +370,21 @@ class OpenSSH_Keypair(object): openssh_privatekey=openssh_privatekey, openssh_publickey=openssh_publickey, fingerprint=fingerprint, - comment=comment + comment=comment, ) @classmethod - def load(cls, path, passphrase=None): + def load(cls, path, passphrase=None, no_public_key=False): """Returns an Openssh_Keypair object loaded from the supplied file path :path: A path to an existing private key to be loaded :passphrase: Secret used to decrypt the private key being loaded + :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') - openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair) + asym_keypair = Asymmetric_Keypair.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) @@ -387,21 +393,31 @@ class OpenSSH_Keypair(object): openssh_privatekey=openssh_privatekey, openssh_publickey=openssh_publickey, fingerprint=fingerprint, - comment=comment + comment=comment, ) @staticmethod - def encode_openssh_privatekey(asym_keypair): + def encode_openssh_privatekey(asym_keypair, key_format): """Returns an OpenSSH encoded private key for a given keypair :asym_keypair: Asymmetric_Keypair from the private key is extracted + :key_format: Format of the encoded private key. """ - # OpenSSH formatted private keys are not available in Cryptography <3.0 - try: - privatekey_format = serialization.PrivateFormat.OpenSSH - except AttributeError: + if key_format == 'SSH': + # Default to PEM format if SSH not available + if not HAS_OPENSSH_PRIVATE_FORMAT: + privatekey_format = serialization.PrivateFormat.PKCS8 + else: + privatekey_format = serialization.PrivateFormat.OpenSSH + elif key_format == 'PKCS8': privatekey_format = serialization.PrivateFormat.PKCS8 + elif key_format == 'PKCS1': + if asym_keypair.key_type == 'ed25519': + raise InvalidKeyFormatError("ed25519 keys cannot be represented in PKCS1 format") + privatekey_format = serialization.PrivateFormat.TraditionalOpenSSL + else: + raise InvalidKeyFormatError("The accepted private key formats are SSH, PKCS8, and PKCS1") encoded_privatekey = asym_keypair.private_key.private_bytes( encoding=serialization.Encoding.PEM, @@ -425,7 +441,7 @@ class OpenSSH_Keypair(object): validate_comment(comment) - encoded_publickey += (" %s" % comment).encode(encoding=_TEXT_ENCODING) + encoded_publickey += (" %s" % comment).encode(encoding=_TEXT_ENCODING) if comment else b'' return encoded_publickey @@ -502,7 +518,7 @@ class OpenSSH_Keypair(object): validate_comment(comment) self.__comment = comment - encoded_comment = (" %s" % self.__comment).encode(encoding=_TEXT_ENCODING) + encoded_comment = (" %s" % self.__comment).encode(encoding=_TEXT_ENCODING) if self.__comment else b'' self.__openssh_publickey = b' '.join(self.__openssh_publickey.split(b' ', 2)[:2]) + encoded_comment return self.__openssh_publickey @@ -513,7 +529,7 @@ class OpenSSH_Keypair(object): """ self.__asym_keypair.update_passphrase(passphrase) - self.__openssh_privatekey = OpenSSH_Keypair.encode_openssh_privatekey(self.__asym_keypair) + self.__openssh_privatekey = OpenSSH_Keypair.encode_openssh_privatekey(self.__asym_keypair, 'SSH') def load_privatekey(path, passphrase, key_format): @@ -549,7 +565,22 @@ def load_privatekey(path, passphrase, key_format): ) except ValueError as e: - raise InvalidPrivateKeyFileError(e) + # Revert to PEM if key could not be loaded in SSH format + if key_format == 'SSH': + try: + privatekey = privatekey_loaders['PEM']( + data=content, + password=passphrase, + backend=backend, + ) + except ValueError as e: + raise InvalidPrivateKeyFileError(e) + except TypeError as e: + raise InvalidPassphraseError(e) + except UnsupportedAlgorithm as e: + raise InvalidAlgorithmError(e) + else: + raise InvalidPrivateKeyFileError(e) except TypeError as e: raise InvalidPassphraseError(e) except UnsupportedAlgorithm as e: @@ -645,4 +676,4 @@ def calculate_fingerprint(openssh_publickey): decoded_pubkey = b64decode(openssh_publickey.split(b' ')[1]) digest.update(decoded_pubkey) - return b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip('=') + return 'SHA256:%s' % b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip('=') diff --git a/plugins/modules/openssh_keypair.py b/plugins/modules/openssh_keypair.py index a104045a..69eab3c4 100644 --- a/plugins/modules/openssh_keypair.py +++ b/plugins/modules/openssh_keypair.py @@ -7,7 +7,6 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type - DOCUMENTATION = ''' --- module: openssh_keypair @@ -19,6 +18,8 @@ description: 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) options: state: description: @@ -55,6 +56,23 @@ options: description: - Provides a new comment to the public key. type: str + passphrase: + description: + - Passphrase used to decrypt an existing private key or encrypt a newly generated private key. + - Passphrases are not supported for I(type=rsa1). + 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). + - 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. + type: str + default: auto + choices: + - auto + version_added: 1.7.0 regenerate: description: - Allows to configure in which situations the module is allowed to regenerate private keys. @@ -101,6 +119,11 @@ EXAMPLES = ''' community.crypto.openssh_keypair: path: /tmp/id_ssh_rsa +- name: Generate an OpenSSH keypair with the default values (4096 bits, rsa) and encrypted private key + community.crypto.openssh_keypair: + path: /tmp/id_ssh_rsa + passphrase: super_secret_password + - name: Generate an OpenSSH rsa keypair with a different size (2048 bits) community.crypto.openssh_keypair: path: /tmp/id_ssh_rsa @@ -153,9 +176,19 @@ comment: import errno import os import stat +from distutils.version import LooseVersion -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils._text import to_native, to_text, to_bytes + +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, +) class KeypairError(Exception): @@ -171,6 +204,7 @@ class Keypair(object): 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 @@ -180,6 +214,44 @@ class Keypair(object): 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: @@ -204,39 +276,71 @@ class Keypair(object): def generate(self, module): # generate a keypair if self.force or not self.isPrivateKeyValid(module, perms_required=False): - 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', ""]) - 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 - 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') + + 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): - pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path]) - pubkey = pubkey[1].strip('\n') + 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: @@ -252,9 +356,14 @@ class Keypair(object): 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) - args = [module.get_bin_path('ssh-keygen', True), - '-q', '-o', '-c', '-C', self.comment, '-f', self.path] - module.run_command(args) + 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.') @@ -269,11 +378,26 @@ class Keypair(object): 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 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 + 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): @@ -365,8 +489,16 @@ class Keypair(object): pubkey_parts = _parse_pubkey(_get_pubkey_content()) - pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path]) - pubkey = pubkey[1].strip('\n') + 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: @@ -438,6 +570,8 @@ def main(): default='partial_idempotence', 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']) ), supports_check_mode=True, add_file_common_args=True, diff --git a/tests/integration/targets/openssh_keypair/meta/main.yml b/tests/integration/targets/openssh_keypair/meta/main.yml index dc973f4e..5d9abb12 100644 --- a/tests/integration/targets/openssh_keypair/meta/main.yml +++ b/tests/integration/targets/openssh_keypair/meta/main.yml @@ -1,2 +1,3 @@ dependencies: - setup_ssh_keygen + - setup_openssl diff --git a/tests/integration/targets/openssh_keypair/tasks/main.yml b/tests/integration/targets/openssh_keypair/tasks/main.yml index 993c8ad4..bc76bbda 100644 --- a/tests/integration/targets/openssh_keypair/tasks/main.yml +++ b/tests/integration/targets/openssh_keypair/tasks/main.yml @@ -4,6 +4,9 @@ # 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) openssh_keypair: path: '{{ output_dir }}/privatekey1' @@ -124,7 +127,7 @@ register: privatekey7_modified_result - name: Generate password protected key - command: 'ssh-keygen -f {{ output_dir }}/privatekey8 -N password' + command: 'ssh-keygen -f {{ output_dir }}/privatekey8 -N {{ passphrase }}' - name: Try to modify the password protected key - should fail openssh_keypair: @@ -140,6 +143,58 @@ 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 + 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 @@ -152,8 +207,9 @@ size: 1024 loop: "{{ regenerate_values }}" - name: Regenerate - setup password protected keys - command: 'ssh-keygen -f {{ output_dir }}/regenerate-b-{{ item }} -N password' + 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 }}' @@ -162,6 +218,10 @@ 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: @@ -225,6 +285,29 @@ - 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 }}' @@ -245,6 +328,28 @@ - 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 }}' diff --git a/tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml b/tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml new file mode 100644 index 00000000..6012430d --- /dev/null +++ b/tests/integration/targets/openssh_keypair/tasks/setup_bcrypt.yml @@ -0,0 +1,24 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- name: Attempt to install dependencies for OpenSSH > 7.8 + block: + - name: Ensure bcrypt 3.1.5 available + become: true + pip: + name: bcrypt==3.1.5 + extra_args: "-c {{ remote_constraints }}" + + - name: Register bcrypt version + command: "{{ ansible_python.executable }} -c 'import bcrypt; print(bcrypt.__version__)'" + register: bcrypt_version + ignore_errors: true + +- name: Ensure bcrypt_version is defined + set_fact: + bcrypt_version: + stdout: 0.0 + when: bcrypt_version is not defined \ 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 e9d76a7a..cb1f05e1 100644 --- a/tests/integration/targets/openssh_keypair/tests/validate.yml +++ b/tests/integration/targets/openssh_keypair/tests/validate.yml @@ -138,3 +138,31 @@ assert: that: - privatekey8_result_force is changed + +- block: + - name: 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 + 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 + assert: + that: + - privatekey10_result_force is changed + + - name: 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 + assert: + that: + - privatekey11_result is success + when: cryptography_version.stdout is version('3.0', '>=') and bcrypt_version.stdout is version('3.1.5', '>=') diff --git a/tests/integration/targets/openssh_keypair/vars/main.yml b/tests/integration/targets/openssh_keypair/vars/main.yml index 81eb611f..cc350c81 100644 --- a/tests/integration/targets/openssh_keypair/vars/main.yml +++ b/tests/integration/targets/openssh_keypair/vars/main.yml @@ -1,4 +1,5 @@ --- +passphrase: password regenerate_values: - never - fail