Skip to content

Commit 2454636

Browse files
committed
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
1 parent a2f877f commit 2454636

6 files changed

Lines changed: 117 additions & 63 deletions

File tree

openstackclient/compute/v2/keypair.py

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515

1616
"""Keypair action implementations"""
1717

18+
import collections
1819
import io
1920
import logging
2021
import os
21-
import sys
2222

23+
from cryptography.hazmat.primitives.asymmetric import ed25519
24+
from cryptography.hazmat.primitives import serialization
2325
from openstack import utils as sdk_utils
2426
from osc_lib.command import command
2527
from osc_lib import exceptions
@@ -30,6 +32,27 @@
3032

3133

3234
LOG = logging.getLogger(__name__)
35+
Keypair = collections.namedtuple('Keypair', 'private_key public_key')
36+
37+
38+
def _generate_keypair():
39+
"""Generate a Ed25519 keypair in OpenSSH format.
40+
41+
:returns: A `Keypair` named tuple with the generated private and public
42+
keys.
43+
"""
44+
key = ed25519.Ed25519PrivateKey.generate()
45+
private_key = key.private_bytes(
46+
serialization.Encoding.PEM,
47+
serialization.PrivateFormat.OpenSSH,
48+
serialization.NoEncryption()
49+
).decode()
50+
public_key = key.public_key().public_bytes(
51+
serialization.Encoding.OpenSSH,
52+
serialization.PublicFormat.OpenSSH
53+
).decode()
54+
55+
return Keypair(private_key, public_key)
3356

3457

3558
def _get_keypair_columns(item, hide_pub_key=False, hide_priv_key=False):
@@ -59,30 +82,37 @@ def get_parser(self, prog_name):
5982
key_group.add_argument(
6083
'--public-key',
6184
metavar='<file>',
62-
help=_("Filename for public key to add. If not used, "
63-
"creates a private key.")
85+
help=_(
86+
"Filename for public key to add. "
87+
"If not used, generates a private key in ssh-ed25519 format. "
88+
"To generate keys in other formats, including the legacy "
89+
"ssh-rsa format, you must use an external tool such as "
90+
"ssh-keygen and specify this argument."
91+
),
6492
)
6593
key_group.add_argument(
6694
'--private-key',
6795
metavar='<file>',
68-
help=_("Filename for private key to save. If not used, "
69-
"print private key in console.")
96+
help=_(
97+
"Filename for private key to save. "
98+
"If not used, print private key in console."
99+
)
70100
)
71101
parser.add_argument(
72102
'--type',
73103
metavar='<type>',
74104
choices=['ssh', 'x509'],
75105
help=_(
76-
"Keypair type. Can be ssh or x509. "
77-
"(Supported by API versions '2.2' - '2.latest')"
106+
'Keypair type '
107+
'(supported by --os-compute-api-version 2.2 or above)'
78108
),
79109
)
80110
parser.add_argument(
81111
'--user',
82112
metavar='<user>',
83113
help=_(
84-
'The owner of the keypair. (admin only) (name or ID). '
85-
'Requires ``--os-compute-api-version`` 2.10 or greater.'
114+
'The owner of the keypair (admin only) (name or ID) '
115+
'(supported by --os-compute-api-version 2.10 or above)'
86116
),
87117
)
88118
identity_common.add_user_domain_option_to_parser(parser)
@@ -96,19 +126,43 @@ def take_action(self, parsed_args):
96126
'name': parsed_args.name
97127
}
98128

99-
public_key = parsed_args.public_key
100-
if public_key:
129+
if parsed_args.public_key:
130+
generated_keypair = None
101131
try:
102132
with io.open(os.path.expanduser(parsed_args.public_key)) as p:
103133
public_key = p.read()
104134
except IOError as e:
105135
msg = _("Key file %(public_key)s not found: %(exception)s")
106136
raise exceptions.CommandError(
107-
msg % {"public_key": parsed_args.public_key,
108-
"exception": e}
137+
msg % {
138+
"public_key": parsed_args.public_key,
139+
"exception": e,
140+
}
109141
)
110142

111143
kwargs['public_key'] = public_key
144+
else:
145+
generated_keypair = _generate_keypair()
146+
kwargs['public_key'] = generated_keypair.public_key
147+
148+
# If user have us a file, save private key into specified file
149+
if parsed_args.private_key:
150+
try:
151+
with io.open(
152+
os.path.expanduser(parsed_args.private_key), 'w+'
153+
) as p:
154+
p.write(generated_keypair.private_key)
155+
except IOError as e:
156+
msg = _(
157+
"Key file %(private_key)s can not be saved: "
158+
"%(exception)s"
159+
)
160+
raise exceptions.CommandError(
161+
msg % {
162+
"private_key": parsed_args.private_key,
163+
"exception": e,
164+
}
165+
)
112166

113167
if parsed_args.type:
114168
if not sdk_utils.supports_microversion(compute_client, '2.2'):
@@ -136,32 +190,17 @@ def take_action(self, parsed_args):
136190

137191
keypair = compute_client.create_keypair(**kwargs)
138192

139-
private_key = parsed_args.private_key
140-
# Save private key into specified file
141-
if private_key:
142-
try:
143-
with io.open(
144-
os.path.expanduser(parsed_args.private_key), 'w+'
145-
) as p:
146-
p.write(keypair.private_key)
147-
except IOError as e:
148-
msg = _("Key file %(private_key)s can not be saved: "
149-
"%(exception)s")
150-
raise exceptions.CommandError(
151-
msg % {"private_key": parsed_args.private_key,
152-
"exception": e}
153-
)
154193
# NOTE(dtroyer): how do we want to handle the display of the private
155194
# key when it needs to be communicated back to the user
156195
# For now, duplicate nova keypair-add command output
157-
if public_key or private_key:
196+
if parsed_args.public_key or parsed_args.private_key:
158197
display_columns, columns = _get_keypair_columns(
159198
keypair, hide_pub_key=True, hide_priv_key=True)
160199
data = utils.get_item_properties(keypair, columns)
161200

162201
return (display_columns, data)
163202
else:
164-
sys.stdout.write(keypair.private_key)
203+
self.app.stdout.write(generated_keypair.private_key)
165204
return ({}, {})
166205

167206

@@ -405,5 +444,5 @@ def take_action(self, parsed_args):
405444
data = utils.get_item_properties(keypair, columns)
406445
return (display_columns, data)
407446
else:
408-
sys.stdout.write(keypair.public_key)
447+
self.app.stdout.write(keypair.public_key)
409448
return ({}, {})

openstackclient/tests/functional/compute/v2/test_keypair.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,24 +117,28 @@ def test_keypair_create_private_key(self):
117117
self.assertIsNotNone(cmd_output.get('user_id'))
118118
self.assertIsNotNone(cmd_output.get('fingerprint'))
119119
pk_content = f.read()
120-
self.assertInOutput('-----BEGIN RSA PRIVATE KEY-----', pk_content)
120+
self.assertInOutput(
121+
'-----BEGIN OPENSSH PRIVATE KEY-----', pk_content,
122+
)
121123
self.assertRegex(pk_content, "[0-9A-Za-z+/]+[=]{0,3}\n")
122-
self.assertInOutput('-----END RSA PRIVATE KEY-----', pk_content)
124+
self.assertInOutput(
125+
'-----END OPENSSH PRIVATE KEY-----', pk_content,
126+
)
123127

124128
def test_keypair_create(self):
125129
"""Test keypair create command.
126130
127131
Test steps:
128132
1) Create keypair in setUp
129-
2) Check RSA private key in output
133+
2) Check Ed25519 private key in output
130134
3) Check for new keypair in keypairs list
131135
"""
132136
NewName = data_utils.rand_name('TestKeyPairCreated')
133137
raw_output = self.openstack('keypair create ' + NewName)
134138
self.addCleanup(self.openstack, 'keypair delete ' + NewName)
135-
self.assertInOutput('-----BEGIN RSA PRIVATE KEY-----', raw_output)
139+
self.assertInOutput('-----BEGIN OPENSSH PRIVATE KEY-----', raw_output)
136140
self.assertRegex(raw_output, "[0-9A-Za-z+/]+[=]{0,3}\n")
137-
self.assertInOutput('-----END RSA PRIVATE KEY-----', raw_output)
141+
self.assertInOutput('-----END OPENSSH PRIVATE KEY-----', raw_output)
138142
self.assertIn(NewName, self.keypair_list())
139143

140144
def test_keypair_delete_not_existing(self):

openstackclient/tests/unit/compute/v2/fakes.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ class FakeKeypair(object):
793793
"""Fake one or more keypairs."""
794794

795795
@staticmethod
796-
def create_one_keypair(attrs=None, no_pri=False):
796+
def create_one_keypair(attrs=None):
797797
"""Create a fake keypair
798798
799799
:param dict attrs:
@@ -811,8 +811,6 @@ def create_one_keypair(attrs=None, no_pri=False):
811811
'public_key': 'dummy',
812812
'user_id': 'user'
813813
}
814-
if not no_pri:
815-
keypair_info['private_key'] = 'private_key'
816814

817815
# Overwrite default attributes.
818816
keypair_info.update(attrs)

openstackclient/tests/unit/compute/v2/test_keypair.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ def setUp(self):
5454

5555
class TestKeypairCreate(TestKeypair):
5656

57-
keypair = compute_fakes.FakeKeypair.create_one_keypair()
58-
5957
def setUp(self):
60-
super(TestKeypairCreate, self).setUp()
58+
super().setUp()
59+
60+
self.keypair = compute_fakes.FakeKeypair.create_one_keypair()
6161

6262
self.columns = (
6363
'fingerprint',
@@ -77,8 +77,11 @@ def setUp(self):
7777

7878
self.sdk_client.create_keypair.return_value = self.keypair
7979

80-
def test_key_pair_create_no_options(self):
81-
80+
@mock.patch.object(
81+
keypair, '_generate_keypair',
82+
return_value=keypair.Keypair('private', 'public'),
83+
)
84+
def test_key_pair_create_no_options(self, mock_generate):
8285
arglist = [
8386
self.keypair.name,
8487
]
@@ -90,18 +93,14 @@ def test_key_pair_create_no_options(self):
9093
columns, data = self.cmd.take_action(parsed_args)
9194

9295
self.sdk_client.create_keypair.assert_called_with(
93-
name=self.keypair.name
96+
name=self.keypair.name,
97+
public_key=mock_generate.return_value.public_key,
9498
)
9599

96100
self.assertEqual({}, columns)
97101
self.assertEqual({}, data)
98102

99103
def test_keypair_create_public_key(self):
100-
# overwrite the setup one because we want to omit private_key
101-
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
102-
no_pri=True)
103-
self.sdk_client.create_keypair.return_value = self.keypair
104-
105104
self.data = (
106105
self.keypair.fingerprint,
107106
self.keypair.name,
@@ -135,7 +134,11 @@ def test_keypair_create_public_key(self):
135134
self.assertEqual(self.columns, columns)
136135
self.assertEqual(self.data, data)
137136

138-
def test_keypair_create_private_key(self):
137+
@mock.patch.object(
138+
keypair, '_generate_keypair',
139+
return_value=keypair.Keypair('private', 'public'),
140+
)
141+
def test_keypair_create_private_key(self, mock_generate):
139142
tmp_pk_file = '/tmp/kp-file-' + uuid.uuid4().hex
140143
arglist = [
141144
'--private-key', tmp_pk_file,
@@ -156,19 +159,20 @@ def test_keypair_create_private_key(self):
156159

157160
self.sdk_client.create_keypair.assert_called_with(
158161
name=self.keypair.name,
162+
public_key=mock_generate.return_value.public_key,
159163
)
160164

161165
mock_open.assert_called_once_with(tmp_pk_file, 'w+')
162-
m_file.write.assert_called_once_with(self.keypair.private_key)
166+
m_file.write.assert_called_once_with(
167+
mock_generate.return_value.private_key,
168+
)
163169

164170
self.assertEqual(self.columns, columns)
165171
self.assertEqual(self.data, data)
166172

167173
@mock.patch.object(sdk_utils, 'supports_microversion', return_value=True)
168174
def test_keypair_create_with_key_type(self, sm_mock):
169175
for key_type in ['x509', 'ssh']:
170-
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
171-
no_pri=True)
172176
self.sdk_client.create_keypair.return_value = self.keypair
173177

174178
self.data = (
@@ -233,8 +237,12 @@ def test_keypair_create_with_key_type_pre_v22(self, sm_mock):
233237
'--os-compute-api-version 2.2 or greater is required',
234238
str(ex))
235239

240+
@mock.patch.object(
241+
keypair, '_generate_keypair',
242+
return_value=keypair.Keypair('private', 'public'),
243+
)
236244
@mock.patch.object(sdk_utils, 'supports_microversion', return_value=True)
237-
def test_key_pair_create_with_user(self, sm_mock):
245+
def test_key_pair_create_with_user(self, sm_mock, mock_generate):
238246
arglist = [
239247
'--user', identity_fakes.user_name,
240248
self.keypair.name,
@@ -250,6 +258,7 @@ def test_key_pair_create_with_user(self, sm_mock):
250258
self.sdk_client.create_keypair.assert_called_with(
251259
name=self.keypair.name,
252260
user_id=identity_fakes.user_id,
261+
public_key=mock_generate.return_value.public_key,
253262
)
254263

255264
self.assertEqual({}, columns)
@@ -673,9 +682,6 @@ def test_keypair_show_no_options(self):
673682
self.cmd, arglist, verifylist)
674683

675684
def test_keypair_show(self):
676-
# overwrite the setup one because we want to omit private_key
677-
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
678-
no_pri=True)
679685
self.sdk_client.find_keypair.return_value = self.keypair
680686

681687
self.data = (
@@ -704,7 +710,6 @@ def test_keypair_show(self):
704710
self.assertEqual(self.data, data)
705711

706712
def test_keypair_show_public(self):
707-
708713
arglist = [
709714
'--public-key',
710715
self.keypair.name
@@ -723,10 +728,6 @@ def test_keypair_show_public(self):
723728

724729
@mock.patch.object(sdk_utils, 'supports_microversion', return_value=True)
725730
def test_keypair_show_with_user(self, sm_mock):
726-
727-
# overwrite the setup one because we want to omit private_key
728-
self.keypair = compute_fakes.FakeKeypair.create_one_keypair(
729-
no_pri=True)
730731
self.sdk_client.find_keypair.return_value = self.keypair
731732

732733
self.data = (
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
features:
3+
- |
4+
The ``openstack keypair create`` command will now generate keypairs on the
5+
client side in ssh-ed25519 format. The Compute service no longer supports
6+
server-side key generation starting with ``--os-compute-api-version 2.92``
7+
while the use of ssh-ed25519 is necessary as support for ssh-rsa has been
8+
disabled by default starting in OpenSSH 8.8, which prevents its use in
9+
guests using this version of OpenSSH in the default configuration.
10+
ssh-ed25519 support is widespread and is supported by OpenSSH 6.5 or later
11+
and Dropbear 2020.79 or later.

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
pbr!=2.1.0,>=2.0.0 # Apache-2.0
66

7+
cryptography>=2.7 # BSD/Apache-2.0
78
cliff>=3.5.0 # Apache-2.0
89
iso8601>=0.1.11 # MIT
910
openstacksdk>=0.103.0 # Apache-2.0

0 commit comments

Comments
 (0)