compute: Generate SSH keypairs ourselves
Starting with the 2.92 microversion, nova will no longer generate SSH keys. Avoid breaking users by generating keypairs ourselves using the cryptography library, which was already an indirect dependency through openstacksdk. Change-Id: I3ad2732f70854ab72da0947f00847351dda23944 Implements: blueprint keypair-generation-removal
This commit is contained in:
parent
a2f877f70c
commit
2454636386
@ -15,11 +15,13 @@
|
||||
|
||||
"""Keypair action implementations"""
|
||||
|
||||
import collections
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from openstack import utils as sdk_utils
|
||||
from osc_lib.command import command
|
||||
from osc_lib import exceptions
|
||||
@ -30,6 +32,27 @@ from openstackclient.identity import common as identity_common
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
Keypair = collections.namedtuple('Keypair', 'private_key public_key')
|
||||
|
||||
|
||||
def _generate_keypair():
|
||||
"""Generate a Ed25519 keypair in OpenSSH format.
|
||||
|
||||
:returns: A `Keypair` named tuple with the generated private and public
|
||||
keys.
|
||||
"""
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
private_key = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption()
|
||||
).decode()
|
||||
public_key = key.public_key().public_bytes(
|
||||
serialization.Encoding.OpenSSH,
|
||||
serialization.PublicFormat.OpenSSH
|
||||
).decode()
|
||||
|
||||
return Keypair(private_key, public_key)
|
||||
|
||||
|
||||
def _get_keypair_columns(item, hide_pub_key=False, hide_priv_key=False):
|
||||
@ -59,30 +82,37 @@ class CreateKeypair(command.ShowOne):
|
||||
key_group.add_argument(
|
||||
'--public-key',
|
||||
metavar='<file>',
|
||||
help=_("Filename for public key to add. If not used, "
|
||||
"creates a private key.")
|
||||
help=_(
|
||||
"Filename for public key to add. "
|
||||
"If not used, generates a private key in ssh-ed25519 format. "
|
||||
"To generate keys in other formats, including the legacy "
|
||||
"ssh-rsa format, you must use an external tool such as "
|
||||
"ssh-keygen and specify this argument."
|
||||
),
|
||||
)
|
||||
key_group.add_argument(
|
||||
'--private-key',
|
||||
metavar='<file>',
|
||||
help=_("Filename for private key to save. If not used, "
|
||||
"print private key in console.")
|
||||
help=_(
|
||||
"Filename for private key to save. "
|
||||
"If not used, print private key in console."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
'--type',
|
||||
metavar='<type>',
|
||||
choices=['ssh', 'x509'],
|
||||
help=_(
|
||||
"Keypair type. Can be ssh or x509. "
|
||||
"(Supported by API versions '2.2' - '2.latest')"
|
||||
'Keypair type '
|
||||
'(supported by --os-compute-api-version 2.2 or above)'
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--user',
|
||||
metavar='<user>',
|
||||
help=_(
|
||||
'The owner of the keypair. (admin only) (name or ID). '
|
||||
'Requires ``--os-compute-api-version`` 2.10 or greater.'
|
||||
'The owner of the keypair (admin only) (name or ID) '
|
||||
'(supported by --os-compute-api-version 2.10 or above)'
|
||||
),
|
||||
)
|
||||
identity_common.add_user_domain_option_to_parser(parser)
|
||||
@ -96,19 +126,43 @@ class CreateKeypair(command.ShowOne):
|
||||
'name': parsed_args.name
|
||||
}
|
||||
|
||||
public_key = parsed_args.public_key
|
||||
if public_key:
|
||||
if parsed_args.public_key:
|
||||
generated_keypair = None
|
||||
try:
|
||||
with io.open(os.path.expanduser(parsed_args.public_key)) as p:
|
||||
public_key = p.read()
|
||||
except IOError as e:
|
||||
msg = _("Key file %(public_key)s not found: %(exception)s")
|
||||
raise exceptions.CommandError(
|
||||
msg % {"public_key": parsed_args.public_key,
|
||||
"exception": e}
|
||||
msg % {
|
||||
"public_key": parsed_args.public_key,
|
||||
"exception": e,
|
||||
}
|
||||
)
|
||||
|
||||
kwargs['public_key'] = public_key
|
||||
else:
|
||||
generated_keypair = _generate_keypair()
|
||||
kwargs['public_key'] = generated_keypair.public_key
|
||||
|
||||
# If user have us a file, save private key into specified file
|
||||
if parsed_args.private_key:
|
||||
try:
|
||||
with io.open(
|
||||
os.path.expanduser(parsed_args.private_key), 'w+'
|
||||
) as p:
|
||||
p.write(generated_keypair.private_key)
|
||||
except IOError as e:
|
||||
msg = _(
|
||||
"Key file %(private_key)s can not be saved: "
|
||||
"%(exception)s"
|
||||
)
|
||||
raise exceptions.CommandError(
|
||||
msg % {
|
||||
"private_key": parsed_args.private_key,
|
||||
"exception": e,
|
||||
}
|
||||
)
|
||||
|
||||
if parsed_args.type:
|
||||
if not sdk_utils.supports_microversion(compute_client, '2.2'):
|
||||
@ -136,32 +190,17 @@ class CreateKeypair(command.ShowOne):
|
||||
|
||||
keypair = compute_client.create_keypair(**kwargs)
|
||||
|
||||
private_key = parsed_args.private_key
|
||||
# Save private key into specified file
|
||||
if private_key:
|
||||
try:
|
||||
with io.open(
|
||||
os.path.expanduser(parsed_args.private_key), 'w+'
|
||||
) as p:
|
||||
p.write(keypair.private_key)
|
||||
except IOError as e:
|
||||
msg = _("Key file %(private_key)s can not be saved: "
|
||||
"%(exception)s")
|
||||
raise exceptions.CommandError(
|
||||
msg % {"private_key": parsed_args.private_key,
|
||||
"exception": e}
|
||||
)
|
||||
# NOTE(dtroyer): how do we want to handle the display of the private
|
||||
# key when it needs to be communicated back to the user
|
||||
# For now, duplicate nova keypair-add command output
|
||||
if public_key or private_key:
|
||||
if parsed_args.public_key or parsed_args.private_key:
|
||||
display_columns, columns = _get_keypair_columns(
|
||||
keypair, hide_pub_key=True, hide_priv_key=True)
|
||||
data = utils.get_item_properties(keypair, columns)
|
||||
|
||||
return (display_columns, data)
|
||||
else:
|
||||
sys.stdout.write(keypair.private_key)
|
||||
self.app.stdout.write(generated_keypair.private_key)
|
||||
return ({}, {})
|
||||
|
||||
|
||||
@ -405,5 +444,5 @@ class ShowKeypair(command.ShowOne):
|
||||
data = utils.get_item_properties(keypair, columns)
|
||||
return (display_columns, data)
|
||||
else:
|
||||
sys.stdout.write(keypair.public_key)
|
||||
self.app.stdout.write(keypair.public_key)
|
||||
return ({}, {})
|
||||
|
@ -117,24 +117,28 @@ class KeypairTests(KeypairBase):
|
||||
self.assertIsNotNone(cmd_output.get('user_id'))
|
||||
self.assertIsNotNone(cmd_output.get('fingerprint'))
|
||||
pk_content = f.read()
|
||||
self.assertInOutput('-----BEGIN RSA PRIVATE KEY-----', pk_content)
|
||||
self.assertInOutput(
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----', pk_content,
|
||||
)
|
||||
self.assertRegex(pk_content, "[0-9A-Za-z+/]+[=]{0,3}\n")
|
||||
self.assertInOutput('-----END RSA PRIVATE KEY-----', pk_content)
|
||||
self.assertInOutput(
|
||||
'-----END OPENSSH PRIVATE KEY-----', pk_content,
|
||||
)
|
||||
|
||||
def test_keypair_create(self):
|
||||
"""Test keypair create command.
|
||||
|
||||
Test steps:
|
||||
1) Create keypair in setUp
|
||||
2) Check RSA private key in output
|
||||
2) Check Ed25519 private key in output
|
||||
3) Check for new keypair in keypairs list
|
||||
"""
|
||||
NewName = data_utils.rand_name('TestKeyPairCreated')
|
||||
raw_output = self.openstack('keypair create ' + NewName)
|
||||
self.addCleanup(self.openstack, 'keypair delete ' + NewName)
|
||||
self.assertInOutput('-----BEGIN RSA PRIVATE KEY-----', raw_output)
|
||||
self.assertInOutput('-----BEGIN OPENSSH PRIVATE KEY-----', raw_output)
|
||||
self.assertRegex(raw_output, "[0-9A-Za-z+/]+[=]{0,3}\n")
|
||||
self.assertInOutput('-----END RSA PRIVATE KEY-----', raw_output)
|
||||
self.assertInOutput('-----END OPENSSH PRIVATE KEY-----', raw_output)
|
||||
self.assertIn(NewName, self.keypair_list())
|
||||
|
||||
def test_keypair_delete_not_existing(self):
|
||||
|
@ -793,7 +793,7 @@ class FakeKeypair(object):
|
||||
"""Fake one or more keypairs."""
|
||||
|
||||
@staticmethod
|
||||
def create_one_keypair(attrs=None, no_pri=False):
|
||||
def create_one_keypair(attrs=None):
|
||||
"""Create a fake keypair
|
||||
|
||||
:param dict attrs:
|
||||
@ -811,8 +811,6 @@ class FakeKeypair(object):
|
||||
'public_key': 'dummy',
|
||||
'user_id': 'user'
|
||||
}
|
||||
if not no_pri:
|
||||
keypair_info['private_key'] = 'private_key'
|
||||
|
||||
# Overwrite default attributes.
|
||||
keypair_info.update(attrs)
|
||||
|
@ -54,10 +54,10 @@ class TestKeypair(compute_fakes.TestComputev2):
|
||||
|
||||
class TestKeypairCreate(TestKeypair):
|
||||
|
||||
keypair = compute_fakes.FakeKeypair.create_one_keypair()
|
||||
|
||||
def setUp(self):
|
||||
super(TestKeypairCreate, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
self.keypair = compute_fakes.FakeKeypair.create_one_keypair()
|
||||
|
||||
self.columns = (
|
||||
'fingerprint',
|
||||
@ -77,8 +77,11 @@ class TestKeypairCreate(TestKeypair):
|
||||
|
||||
self.sdk_client.create_keypair.return_value = self.keypair
|
||||
|
||||
def test_key_pair_create_no_options(self):
|
||||
|
||||
@mock.patch.object(
|
||||
keypair, '_generate_keypair',
|
||||
return_value=keypair.Keypair('private', 'public'),
|
||||
)
|
||||
def test_key_pair_create_no_options(self, mock_generate):
|
||||
arglist = [
|
||||
self.keypair.name,
|
||||
]
|
||||
@ -90,18 +93,14 @@ class TestKeypairCreate(TestKeypair):
|
||||
columns, data = self.cmd.take_action(parsed_args)
|
||||
|
||||
self.sdk_client.create_keypair.assert_called_with(
|
||||
name=self.keypair.name
|
||||
name=self.keypair.name,
|
||||
public_key=mock_generate.return_value.public_key,
|
||||
)
|
||||
|
||||
self.assertEqual({}, columns)
|
||||
self.assertEqual({}, data)
|
||||
|
||||
def test_keypair_create_public_key(self):
|
||||
# overwrite the setup one because we want to omit private_key
|
||||
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
|
||||
no_pri=True)
|
||||
self.sdk_client.create_keypair.return_value = self.keypair
|
||||
|
||||
self.data = (
|
||||
self.keypair.fingerprint,
|
||||
self.keypair.name,
|
||||
@ -135,7 +134,11 @@ class TestKeypairCreate(TestKeypair):
|
||||
self.assertEqual(self.columns, columns)
|
||||
self.assertEqual(self.data, data)
|
||||
|
||||
def test_keypair_create_private_key(self):
|
||||
@mock.patch.object(
|
||||
keypair, '_generate_keypair',
|
||||
return_value=keypair.Keypair('private', 'public'),
|
||||
)
|
||||
def test_keypair_create_private_key(self, mock_generate):
|
||||
tmp_pk_file = '/tmp/kp-file-' + uuid.uuid4().hex
|
||||
arglist = [
|
||||
'--private-key', tmp_pk_file,
|
||||
@ -156,10 +159,13 @@ class TestKeypairCreate(TestKeypair):
|
||||
|
||||
self.sdk_client.create_keypair.assert_called_with(
|
||||
name=self.keypair.name,
|
||||
public_key=mock_generate.return_value.public_key,
|
||||
)
|
||||
|
||||
mock_open.assert_called_once_with(tmp_pk_file, 'w+')
|
||||
m_file.write.assert_called_once_with(self.keypair.private_key)
|
||||
m_file.write.assert_called_once_with(
|
||||
mock_generate.return_value.private_key,
|
||||
)
|
||||
|
||||
self.assertEqual(self.columns, columns)
|
||||
self.assertEqual(self.data, data)
|
||||
@ -167,8 +173,6 @@ class TestKeypairCreate(TestKeypair):
|
||||
@mock.patch.object(sdk_utils, 'supports_microversion', return_value=True)
|
||||
def test_keypair_create_with_key_type(self, sm_mock):
|
||||
for key_type in ['x509', 'ssh']:
|
||||
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
|
||||
no_pri=True)
|
||||
self.sdk_client.create_keypair.return_value = self.keypair
|
||||
|
||||
self.data = (
|
||||
@ -233,8 +237,12 @@ class TestKeypairCreate(TestKeypair):
|
||||
'--os-compute-api-version 2.2 or greater is required',
|
||||
str(ex))
|
||||
|
||||
@mock.patch.object(
|
||||
keypair, '_generate_keypair',
|
||||
return_value=keypair.Keypair('private', 'public'),
|
||||
)
|
||||
@mock.patch.object(sdk_utils, 'supports_microversion', return_value=True)
|
||||
def test_key_pair_create_with_user(self, sm_mock):
|
||||
def test_key_pair_create_with_user(self, sm_mock, mock_generate):
|
||||
arglist = [
|
||||
'--user', identity_fakes.user_name,
|
||||
self.keypair.name,
|
||||
@ -250,6 +258,7 @@ class TestKeypairCreate(TestKeypair):
|
||||
self.sdk_client.create_keypair.assert_called_with(
|
||||
name=self.keypair.name,
|
||||
user_id=identity_fakes.user_id,
|
||||
public_key=mock_generate.return_value.public_key,
|
||||
)
|
||||
|
||||
self.assertEqual({}, columns)
|
||||
@ -673,9 +682,6 @@ class TestKeypairShow(TestKeypair):
|
||||
self.cmd, arglist, verifylist)
|
||||
|
||||
def test_keypair_show(self):
|
||||
# overwrite the setup one because we want to omit private_key
|
||||
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
|
||||
no_pri=True)
|
||||
self.sdk_client.find_keypair.return_value = self.keypair
|
||||
|
||||
self.data = (
|
||||
@ -704,7 +710,6 @@ class TestKeypairShow(TestKeypair):
|
||||
self.assertEqual(self.data, data)
|
||||
|
||||
def test_keypair_show_public(self):
|
||||
|
||||
arglist = [
|
||||
'--public-key',
|
||||
self.keypair.name
|
||||
@ -723,10 +728,6 @@ class TestKeypairShow(TestKeypair):
|
||||
|
||||
@mock.patch.object(sdk_utils, 'supports_microversion', return_value=True)
|
||||
def test_keypair_show_with_user(self, sm_mock):
|
||||
|
||||
# overwrite the setup one because we want to omit private_key
|
||||
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
|
||||
no_pri=True)
|
||||
self.sdk_client.find_keypair.return_value = self.keypair
|
||||
|
||||
self.data = (
|
||||
|
@ -0,0 +1,11 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
The ``openstack keypair create`` command will now generate keypairs on the
|
||||
client side in ssh-ed25519 format. The Compute service no longer supports
|
||||
server-side key generation starting with ``--os-compute-api-version 2.92``
|
||||
while the use of ssh-ed25519 is necessary as support for ssh-rsa has been
|
||||
disabled by default starting in OpenSSH 8.8, which prevents its use in
|
||||
guests using this version of OpenSSH in the default configuration.
|
||||
ssh-ed25519 support is widespread and is supported by OpenSSH 6.5 or later
|
||||
and Dropbear 2020.79 or later.
|
@ -4,6 +4,7 @@
|
||||
|
||||
pbr!=2.1.0,>=2.0.0 # Apache-2.0
|
||||
|
||||
cryptography>=2.7 # BSD/Apache-2.0
|
||||
cliff>=3.5.0 # Apache-2.0
|
||||
iso8601>=0.1.11 # MIT
|
||||
openstacksdk>=0.103.0 # Apache-2.0
|
||||
|
Loading…
Reference in New Issue
Block a user