Skip to content

Commit 43f0e10

Browse files
Zuulopenstack-gerrit
authored andcommitted
Merge "compute: Generate SSH keypairs ourselves"
2 parents 8301678 + 2454636 commit 43f0e10

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)