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_formatpull/229/head
parent
37c1540ff4
commit
6100d9b4df
|
@ -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).
|
|
@ -19,24 +19,23 @@ from __future__ import absolute_import, division, print_function
|
||||||
__metaclass__ = type
|
__metaclass__ = type
|
||||||
|
|
||||||
from base64 import b64encode, b64decode
|
from base64 import b64encode, b64decode
|
||||||
|
from distutils.version import LooseVersion
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
from socket import gethostname
|
from socket import gethostname
|
||||||
|
|
||||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
|
|
||||||
HAS_CRYPTOGRAPHY,
|
|
||||||
CRYPTOGRAPHY_HAS_ED25519,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
import cryptography as c
|
||||||
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
||||||
from cryptography.hazmat.backends.openssl import backend
|
from cryptography.hazmat.backends.openssl import backend
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa, padding
|
from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa, padding
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
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
|
HAS_OPENSSH_SUPPORT = True
|
||||||
|
|
||||||
_ALGORITHM_PARAMETERS = {
|
_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
|
HAS_OPENSSH_SUPPORT = False
|
||||||
_ALGORITHM_PARAMETERS = {}
|
_ALGORITHM_PARAMETERS = {}
|
||||||
|
|
||||||
|
@ -192,12 +192,14 @@ class Asymmetric_Keypair(object):
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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
|
"""Returns an Asymmetric_Keypair object loaded from the supplied file path
|
||||||
|
|
||||||
:path: A path to an existing private key to be loaded
|
:path: A path to an existing private key to be loaded
|
||||||
:passphrase: Secret of type bytes used to decrypt the private key being 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:
|
if passphrase:
|
||||||
|
@ -205,8 +207,11 @@ class Asymmetric_Keypair(object):
|
||||||
else:
|
else:
|
||||||
encryption_algorithm = serialization.NoEncryption()
|
encryption_algorithm = serialization.NoEncryption()
|
||||||
|
|
||||||
privatekey = load_privatekey(path, passphrase, key_format)
|
privatekey = load_privatekey(path, passphrase, private_key_format)
|
||||||
publickey = load_publickey(path + '.pub', 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
|
# Ed25519 keys are always of size 256 and do not have a key_size attribute
|
||||||
if isinstance(privatekey, Ed25519PrivateKey):
|
if isinstance(privatekey, Ed25519PrivateKey):
|
||||||
|
@ -352,11 +357,11 @@ class OpenSSH_Keypair(object):
|
||||||
:comment: Comment for a newly generated OpenSSH public key
|
:comment: Comment for a newly generated OpenSSH public key
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not comment:
|
if comment is None:
|
||||||
comment = "%s@%s" % (getuser(), gethostname())
|
comment = "%s@%s" % (getuser(), gethostname())
|
||||||
|
|
||||||
asym_keypair = Asymmetric_Keypair.generate(keytype, size, passphrase)
|
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)
|
openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment)
|
||||||
fingerprint = calculate_fingerprint(openssh_publickey)
|
fingerprint = calculate_fingerprint(openssh_publickey)
|
||||||
|
|
||||||
|
@ -365,20 +370,21 @@ class OpenSSH_Keypair(object):
|
||||||
openssh_privatekey=openssh_privatekey,
|
openssh_privatekey=openssh_privatekey,
|
||||||
openssh_publickey=openssh_publickey,
|
openssh_publickey=openssh_publickey,
|
||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
comment=comment
|
comment=comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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
|
"""Returns an Openssh_Keypair object loaded from the supplied file path
|
||||||
|
|
||||||
:path: A path to an existing private key to be loaded
|
:path: A path to an existing private key to be loaded
|
||||||
:passphrase: Secret used to decrypt the private key being 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')
|
comment = extract_comment(path + '.pub')
|
||||||
asym_keypair = Asymmetric_Keypair.load(path, passphrase, 'SSH')
|
asym_keypair = Asymmetric_Keypair.load(path, passphrase, 'SSH', 'SSH', no_public_key)
|
||||||
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)
|
openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment)
|
||||||
fingerprint = calculate_fingerprint(openssh_publickey)
|
fingerprint = calculate_fingerprint(openssh_publickey)
|
||||||
|
|
||||||
|
@ -387,21 +393,31 @@ class OpenSSH_Keypair(object):
|
||||||
openssh_privatekey=openssh_privatekey,
|
openssh_privatekey=openssh_privatekey,
|
||||||
openssh_publickey=openssh_publickey,
|
openssh_publickey=openssh_publickey,
|
||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
comment=comment
|
comment=comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@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
|
"""Returns an OpenSSH encoded private key for a given keypair
|
||||||
|
|
||||||
:asym_keypair: Asymmetric_Keypair from the private key is extracted
|
: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
|
if key_format == 'SSH':
|
||||||
try:
|
# Default to PEM format if SSH not available
|
||||||
privatekey_format = serialization.PrivateFormat.OpenSSH
|
if not HAS_OPENSSH_PRIVATE_FORMAT:
|
||||||
except AttributeError:
|
privatekey_format = serialization.PrivateFormat.PKCS8
|
||||||
|
else:
|
||||||
|
privatekey_format = serialization.PrivateFormat.OpenSSH
|
||||||
|
elif key_format == 'PKCS8':
|
||||||
privatekey_format = serialization.PrivateFormat.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(
|
encoded_privatekey = asym_keypair.private_key.private_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=serialization.Encoding.PEM,
|
||||||
|
@ -425,7 +441,7 @@ class OpenSSH_Keypair(object):
|
||||||
|
|
||||||
validate_comment(comment)
|
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
|
return encoded_publickey
|
||||||
|
|
||||||
|
@ -502,7 +518,7 @@ class OpenSSH_Keypair(object):
|
||||||
validate_comment(comment)
|
validate_comment(comment)
|
||||||
|
|
||||||
self.__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
|
self.__openssh_publickey = b' '.join(self.__openssh_publickey.split(b' ', 2)[:2]) + encoded_comment
|
||||||
return self.__openssh_publickey
|
return self.__openssh_publickey
|
||||||
|
|
||||||
|
@ -513,7 +529,7 @@ class OpenSSH_Keypair(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.__asym_keypair.update_passphrase(passphrase)
|
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):
|
def load_privatekey(path, passphrase, key_format):
|
||||||
|
@ -549,7 +565,22 @@ def load_privatekey(path, passphrase, key_format):
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
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:
|
except TypeError as e:
|
||||||
raise InvalidPassphraseError(e)
|
raise InvalidPassphraseError(e)
|
||||||
except UnsupportedAlgorithm as e:
|
except UnsupportedAlgorithm as e:
|
||||||
|
@ -645,4 +676,4 @@ def calculate_fingerprint(openssh_publickey):
|
||||||
decoded_pubkey = b64decode(openssh_publickey.split(b' ')[1])
|
decoded_pubkey = b64decode(openssh_publickey.split(b' ')[1])
|
||||||
digest.update(decoded_pubkey)
|
digest.update(decoded_pubkey)
|
||||||
|
|
||||||
return b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip('=')
|
return 'SHA256:%s' % b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip('=')
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
from __future__ import absolute_import, division, print_function
|
from __future__ import absolute_import, division, print_function
|
||||||
__metaclass__ = type
|
__metaclass__ = type
|
||||||
|
|
||||||
|
|
||||||
DOCUMENTATION = '''
|
DOCUMENTATION = '''
|
||||||
---
|
---
|
||||||
module: openssh_keypair
|
module: openssh_keypair
|
||||||
|
@ -19,6 +18,8 @@ description:
|
||||||
or C(ecdsa) private keys."
|
or C(ecdsa) private keys."
|
||||||
requirements:
|
requirements:
|
||||||
- "ssh-keygen"
|
- "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:
|
options:
|
||||||
state:
|
state:
|
||||||
description:
|
description:
|
||||||
|
@ -55,6 +56,23 @@ options:
|
||||||
description:
|
description:
|
||||||
- Provides a new comment to the public key.
|
- Provides a new comment to the public key.
|
||||||
type: str
|
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:
|
regenerate:
|
||||||
description:
|
description:
|
||||||
- Allows to configure in which situations the module is allowed to regenerate private keys.
|
- Allows to configure in which situations the module is allowed to regenerate private keys.
|
||||||
|
@ -101,6 +119,11 @@ EXAMPLES = '''
|
||||||
community.crypto.openssh_keypair:
|
community.crypto.openssh_keypair:
|
||||||
path: /tmp/id_ssh_rsa
|
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)
|
- name: Generate an OpenSSH rsa keypair with a different size (2048 bits)
|
||||||
community.crypto.openssh_keypair:
|
community.crypto.openssh_keypair:
|
||||||
path: /tmp/id_ssh_rsa
|
path: /tmp/id_ssh_rsa
|
||||||
|
@ -153,9 +176,19 @@ comment:
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
from distutils.version import LooseVersion
|
||||||
|
|
||||||
from ansible.module_utils.basic import AnsibleModule
|
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||||
from ansible.module_utils._text import to_native
|
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):
|
class KeypairError(Exception):
|
||||||
|
@ -171,6 +204,7 @@ class Keypair(object):
|
||||||
self.size = module.params['size']
|
self.size = module.params['size']
|
||||||
self.type = module.params['type']
|
self.type = module.params['type']
|
||||||
self.comment = module.params['comment']
|
self.comment = module.params['comment']
|
||||||
|
self.passphrase = module.params['passphrase']
|
||||||
self.changed = False
|
self.changed = False
|
||||||
self.check_mode = module.check_mode
|
self.check_mode = module.check_mode
|
||||||
self.privatekey = None
|
self.privatekey = None
|
||||||
|
@ -180,6 +214,44 @@ class Keypair(object):
|
||||||
if self.regenerate == 'always':
|
if self.regenerate == 'always':
|
||||||
self.force = True
|
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'):
|
if self.type in ('rsa', 'rsa1'):
|
||||||
self.size = 4096 if self.size is None else self.size
|
self.size = 4096 if self.size is None else self.size
|
||||||
if self.size < 1024:
|
if self.size < 1024:
|
||||||
|
@ -204,39 +276,71 @@ class Keypair(object):
|
||||||
def generate(self, module):
|
def generate(self, module):
|
||||||
# generate a keypair
|
# generate a keypair
|
||||||
if self.force or not self.isPrivateKeyValid(module, perms_required=False):
|
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:
|
try:
|
||||||
if os.path.exists(self.path) and not os.access(self.path, os.W_OK):
|
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)
|
os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR)
|
||||||
self.changed = True
|
self.changed = True
|
||||||
stdin_data = None
|
|
||||||
if os.path.exists(self.path):
|
if not self.passphrase:
|
||||||
stdin_data = 'y'
|
args = [
|
||||||
module.run_command(args, data=stdin_data)
|
module.get_bin_path('ssh-keygen', True),
|
||||||
proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path])
|
'-q',
|
||||||
self.fingerprint = proc[1].split()
|
'-N', '',
|
||||||
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
|
'-b', str(self.size),
|
||||||
self.public_key = pubkey[1].strip('\n')
|
'-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:
|
except Exception as e:
|
||||||
self.remove()
|
self.remove()
|
||||||
module.fail_json(msg="%s" % to_native(e))
|
module.fail_json(msg="%s" % to_native(e))
|
||||||
|
|
||||||
elif not self.isPublicKeyValid(module, perms_required=False):
|
elif not self.isPublicKeyValid(module, perms_required=False):
|
||||||
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
|
if not self.passphrase:
|
||||||
pubkey = pubkey[1].strip('\n')
|
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:
|
try:
|
||||||
self.changed = True
|
self.changed = True
|
||||||
with open(self.path + ".pub", "w") as pubkey_f:
|
with open(self.path + ".pub", "w") as pubkey_f:
|
||||||
|
@ -252,9 +356,14 @@ class Keypair(object):
|
||||||
try:
|
try:
|
||||||
if os.path.exists(self.path) and not os.access(self.path, os.W_OK):
|
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)
|
os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR)
|
||||||
args = [module.get_bin_path('ssh-keygen', True),
|
if not self.passphrase:
|
||||||
'-q', '-o', '-c', '-C', self.comment, '-f', self.path]
|
args = [module.get_bin_path('ssh-keygen', True),
|
||||||
module.run_command(args)
|
'-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:
|
except IOError:
|
||||||
module.fail_json(
|
module.fail_json(
|
||||||
msg='Unable to update the comment for the public key.')
|
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):
|
def _check_pass_protected_or_broken_key(self, module):
|
||||||
key_state = module.run_command([module.get_bin_path('ssh-keygen', True),
|
key_state = module.run_command([module.get_bin_path('ssh-keygen', True),
|
||||||
'-P', '', '-yf', self.path], check_rc=False)
|
'-P', '', '-yf', self.path], check_rc=False)
|
||||||
if key_state[0] == 255 or 'is not a public key file' in key_state[2]:
|
if not self.passphrase:
|
||||||
return True
|
if key_state[0] == 255 or 'is not a public key file' in key_state[2]:
|
||||||
if 'incorrect passphrase' in key_state[2] or 'load failed' in key_state[2]:
|
return True
|
||||||
return True
|
if 'incorrect passphrase' in key_state[2] or 'load failed' in key_state[2]:
|
||||||
return False
|
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):
|
def isPrivateKeyValid(self, module, perms_required=True):
|
||||||
|
|
||||||
|
@ -365,8 +489,16 @@ class Keypair(object):
|
||||||
|
|
||||||
pubkey_parts = _parse_pubkey(_get_pubkey_content())
|
pubkey_parts = _parse_pubkey(_get_pubkey_content())
|
||||||
|
|
||||||
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
|
if not self.passphrase:
|
||||||
pubkey = pubkey[1].strip('\n')
|
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):
|
if _pubkey_valid(pubkey):
|
||||||
self.public_key = pubkey
|
self.public_key = pubkey
|
||||||
else:
|
else:
|
||||||
|
@ -438,6 +570,8 @@ def main():
|
||||||
default='partial_idempotence',
|
default='partial_idempotence',
|
||||||
choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always']
|
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,
|
supports_check_mode=True,
|
||||||
add_file_common_args=True,
|
add_file_common_args=True,
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
- setup_ssh_keygen
|
- setup_ssh_keygen
|
||||||
|
- setup_openssl
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
# and should not be used as examples of how to write Ansible roles #
|
# 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: Generate privatekey1 - standard (check mode)
|
||||||
openssh_keypair:
|
openssh_keypair:
|
||||||
path: '{{ output_dir }}/privatekey1'
|
path: '{{ output_dir }}/privatekey1'
|
||||||
|
@ -124,7 +127,7 @@
|
||||||
register: privatekey7_modified_result
|
register: privatekey7_modified_result
|
||||||
|
|
||||||
- name: Generate password protected key
|
- 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
|
- name: Try to modify the password protected key - should fail
|
||||||
openssh_keypair:
|
openssh_keypair:
|
||||||
|
@ -140,6 +143,58 @@
|
||||||
size: 2048
|
size: 2048
|
||||||
register: privatekey8_result_force
|
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
|
- import_tasks: ../tests/validate.yml
|
||||||
|
|
||||||
|
|
||||||
|
@ -152,8 +207,9 @@
|
||||||
size: 1024
|
size: 1024
|
||||||
loop: "{{ regenerate_values }}"
|
loop: "{{ regenerate_values }}"
|
||||||
- name: Regenerate - setup password protected keys
|
- 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 }}"
|
loop: "{{ regenerate_values }}"
|
||||||
|
|
||||||
- name: Regenerate - setup broken keys
|
- name: Regenerate - setup broken keys
|
||||||
copy:
|
copy:
|
||||||
dest: '{{ output_dir }}/regenerate-c-{{ item.0 }}{{ item.1 }}'
|
dest: '{{ output_dir }}/regenerate-c-{{ item.0 }}{{ item.1 }}'
|
||||||
|
@ -162,6 +218,10 @@
|
||||||
with_nested:
|
with_nested:
|
||||||
- "{{ regenerate_values }}"
|
- "{{ regenerate_values }}"
|
||||||
- [ '', '.pub' ]
|
- [ '', '.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)
|
- name: Regenerate - modify broken keys (check mode)
|
||||||
openssh_keypair:
|
openssh_keypair:
|
||||||
|
@ -225,6 +285,29 @@
|
||||||
- result.results[3] is changed
|
- result.results[3] is changed
|
||||||
- result.results[4] 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
|
- name: Regenerate - modify password protected keys
|
||||||
openssh_keypair:
|
openssh_keypair:
|
||||||
path: '{{ output_dir }}/regenerate-b-{{ item }}'
|
path: '{{ output_dir }}/regenerate-b-{{ item }}'
|
||||||
|
@ -245,6 +328,28 @@
|
||||||
- result.results[3] is changed
|
- result.results[3] is changed
|
||||||
- result.results[4] 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)
|
- name: Regenerate - not modify regular keys (check mode)
|
||||||
openssh_keypair:
|
openssh_keypair:
|
||||||
path: '{{ output_dir }}/regenerate-a-{{ item }}'
|
path: '{{ output_dir }}/regenerate-a-{{ item }}'
|
||||||
|
|
|
@ -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
|
|
@ -138,3 +138,31 @@
|
||||||
assert:
|
assert:
|
||||||
that:
|
that:
|
||||||
- privatekey8_result_force is changed
|
- 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', '>=')
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
---
|
---
|
||||||
|
passphrase: password
|
||||||
regenerate_values:
|
regenerate_values:
|
||||||
- never
|
- never
|
||||||
- fail
|
- fail
|
||||||
|
|
Loading…
Reference in New Issue