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
This commit is contained in:
Ajpantuso
2021-05-10 08:47:01 -04:00
committed by GitHub
parent 37c1540ff4
commit 6100d9b4df
8 changed files with 395 additions and 69 deletions

View File

@@ -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,