Add support for ecdsa keys

In FIPS mode, using RSA keys for ssh is fine as long as SHA-1 is
not used for the signature algorithm.  Unfortunately, the version
of cirros used in OpenStack CI does not have a version of dropbear
that supports SHA-2 signatures.  So, any connections from a FIPS
enabled machine will fail as the cirros instance will only support
ssh-rsa (SHA-1 signatures).

To get around this, we add a new option to specify the key type
(validation.ssh_key_type).  This will allow the addition of other
key types in future if needed.

Tempest now supports 'rsa' and 'ecdsa' key types.

We also add a fips job to the experimental queue to test the usage
of the new key type.

Change-Id: Ib59eb8432fa1a2813b3047955157d1b3d24a55f8
This commit is contained in:
Ade Lee 2021-09-04 15:56:34 -04:00
parent fe0ac89a5a
commit 6ded070b51
13 changed files with 88 additions and 9 deletions

View File

@ -0,0 +1,4 @@
- hosts: all
tasks:
- include_role:
name: enable-fips

View File

@ -0,0 +1,6 @@
---
features:
- |
Add parameter to specify the SSH key type. Current options are 'rsa'
(which is the default) and 'ecdsa'. Tempest now supports the importing
and generation of both 'rsa' and 'ecdsa' SSH key types.

View File

@ -6,6 +6,7 @@ cliff!=2.9.0,>=2.8.0 # Apache-2.0
jsonschema>=3.2.0 # MIT jsonschema>=3.2.0 # MIT
testtools>=2.2.0 # MIT testtools>=2.2.0 # MIT
paramiko>=2.7.0 # LGPLv2.1+ paramiko>=2.7.0 # LGPLv2.1+
cryptography>=2.1 # BSD/Apache-2.0
netaddr>=0.7.18 # BSD netaddr>=0.7.18 # BSD
oslo.concurrency>=3.26.0 # Apache-2.0 oslo.concurrency>=3.26.0 # Apache-2.0
oslo.config>=5.2.0 # Apache-2.0 oslo.config>=5.2.0 # Apache-2.0

View File

@ -68,7 +68,8 @@ class AttachInterfacesTestBase(base.BaseV2ComputeTest):
self.image_ssh_password, self.image_ssh_password,
validation_resources['keypair']['private_key'], validation_resources['keypair']['private_key'],
server=server, server=server,
servers_client=self.servers_client) servers_client=self.servers_client,
ssh_key_type=CONF.validation.ssh_key_type)
linux_client.validate_authentication() linux_client.validate_authentication()
def _create_server_get_interfaces(self): def _create_server_get_interfaces(self):

View File

@ -118,7 +118,8 @@ class Manager(clients.ServiceClients):
self.server_groups_client = self.compute.ServerGroupsClient() self.server_groups_client = self.compute.ServerGroupsClient()
self.limits_client = self.compute.LimitsClient() self.limits_client = self.compute.LimitsClient()
self.compute_images_client = self.compute.ImagesClient() self.compute_images_client = self.compute.ImagesClient()
self.keypairs_client = self.compute.KeyPairsClient() self.keypairs_client = self.compute.KeyPairsClient(
ssh_key_type=CONF.validation.ssh_key_type)
self.quotas_client = self.compute.QuotasClient() self.quotas_client = self.compute.QuotasClient()
self.quota_classes_client = self.compute.QuotaClassesClient() self.quota_classes_client = self.compute.QuotaClassesClient()
self.flavors_client = self.compute.FlavorsClient() self.flavors_client = self.compute.FlavorsClient()

View File

@ -48,7 +48,8 @@ class RemoteClient(remote_client.RemoteClient):
console_output_enabled=CONF.compute_feature_enabled.console_output, console_output_enabled=CONF.compute_feature_enabled.console_output,
ssh_shell_prologue=CONF.validation.ssh_shell_prologue, ssh_shell_prologue=CONF.validation.ssh_shell_prologue,
ping_count=CONF.validation.ping_count, ping_count=CONF.validation.ping_count,
ping_size=CONF.validation.ping_size) ping_size=CONF.validation.ping_size,
ssh_key_type=CONF.validation.ssh_key_type)
# Note that this method will not work on SLES11 guests, as they do # Note that this method will not work on SLES11 guests, as they do
# not support the TYPE column on lsblk # not support the TYPE column on lsblk

View File

@ -970,6 +970,10 @@ ValidationGroup = [
default='public', default='public',
help="Network used for SSH connections. Ignored if " help="Network used for SSH connections. Ignored if "
"connect_method=floating."), "connect_method=floating."),
cfg.StrOpt('ssh_key_type',
default='rsa',
help='Type of key to use for ssh connections. '
'Valid types are rsa, ecdsa'),
] ]
volume_group = cfg.OptGroup(name='volume', volume_group = cfg.OptGroup(name='volume',

View File

@ -37,7 +37,7 @@ class Client(object):
def __init__(self, host, username, password=None, timeout=300, pkey=None, def __init__(self, host, username, password=None, timeout=300, pkey=None,
channel_timeout=10, look_for_keys=False, key_filename=None, channel_timeout=10, look_for_keys=False, key_filename=None,
port=22, proxy_client=None): port=22, proxy_client=None, ssh_key_type='rsa'):
"""SSH client. """SSH client.
Many of parameters are just passed to the underlying implementation Many of parameters are just passed to the underlying implementation
@ -59,6 +59,7 @@ class Client(object):
:param proxy_client: Another SSH client to provide a transport :param proxy_client: Another SSH client to provide a transport
for ssh-over-ssh. The default is None, which means for ssh-over-ssh. The default is None, which means
not to use ssh-over-ssh. not to use ssh-over-ssh.
:param ssh_key_type: ssh key type (rsa, ecdsa)
:type proxy_client: ``tempest.lib.common.ssh.Client`` object :type proxy_client: ``tempest.lib.common.ssh.Client`` object
""" """
self.host = host self.host = host
@ -66,8 +67,15 @@ class Client(object):
self.port = port self.port = port
self.password = password self.password = password
if isinstance(pkey, str): if isinstance(pkey, str):
if ssh_key_type == 'rsa':
pkey = paramiko.RSAKey.from_private_key( pkey = paramiko.RSAKey.from_private_key(
io.StringIO(str(pkey))) io.StringIO(str(pkey)))
elif ssh_key_type == 'ecdsa':
pkey = paramiko.ECDSAKey.from_private_key(
io.StringIO(str(pkey)))
else:
raise exceptions.SSHClientUnsupportedKeyType(
key_type=ssh_key_type)
self.pkey = pkey self.pkey = pkey
self.look_for_keys = look_for_keys self.look_for_keys = look_for_keys
self.key_filename = key_filename self.key_filename = key_filename

View File

@ -69,7 +69,7 @@ class RemoteClient(object):
server=None, servers_client=None, ssh_timeout=300, server=None, servers_client=None, ssh_timeout=300,
connect_timeout=60, console_output_enabled=True, connect_timeout=60, console_output_enabled=True,
ssh_shell_prologue="set -eu -o pipefail; PATH=$PATH:/sbin;", ssh_shell_prologue="set -eu -o pipefail; PATH=$PATH:/sbin;",
ping_count=1, ping_size=56): ping_count=1, ping_size=56, ssh_key_type='rsa'):
"""Executes commands in a VM over ssh """Executes commands in a VM over ssh
:param ip_address: IP address to ssh to :param ip_address: IP address to ssh to
@ -84,6 +84,7 @@ class RemoteClient(object):
:param ssh_shell_prologue: Shell fragments to use before command :param ssh_shell_prologue: Shell fragments to use before command
:param ping_count: Number of ping packets :param ping_count: Number of ping packets
:param ping_size: Packet size for ping packets :param ping_size: Packet size for ping packets
:param ssh_key_type: ssh key type (rsa, ecdsa)
""" """
self.server = server self.server = server
self.servers_client = servers_client self.servers_client = servers_client
@ -92,10 +93,12 @@ class RemoteClient(object):
self.ssh_shell_prologue = ssh_shell_prologue self.ssh_shell_prologue = ssh_shell_prologue
self.ping_count = ping_count self.ping_count = ping_count
self.ping_size = ping_size self.ping_size = ping_size
self.ssh_key_type = ssh_key_type
self.ssh_client = ssh.Client(ip_address, username, password, self.ssh_client = ssh.Client(ip_address, username, password,
ssh_timeout, pkey=pkey, ssh_timeout, pkey=pkey,
channel_timeout=connect_timeout) channel_timeout=connect_timeout,
ssh_key_type=ssh_key_type)
@debug_ssh @debug_ssh
def exec_command(self, cmd): def exec_command(self, cmd):

View File

@ -256,6 +256,10 @@ class SSHClientProxyClientLoop(TempestException):
"%(port)s and username: %(username)s as parent") "%(port)s and username: %(username)s as parent")
class SSHClientUnsupportedKeyType(TempestException):
message = ("SSH client: unsupported key type %(key_type)s")
class UnknownServiceClient(TempestException): class UnknownServiceClient(TempestException):
message = "Service clients named %(services)s are not known" message = "Service clients named %(services)s are not known"

View File

@ -15,6 +15,10 @@
from urllib import parse as urllib from urllib import parse as urllib
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from oslo_serialization import jsonutils as json from oslo_serialization import jsonutils as json
from tempest.lib.api_schema.response.compute.v2_1 import keypairs as schemav21 from tempest.lib.api_schema.response.compute.v2_1 import keypairs as schemav21
@ -28,6 +32,12 @@ class KeyPairsClient(base_compute_client.BaseComputeClient):
schema_versions_info = [{'min': None, 'max': '2.1', 'schema': schemav21}, schema_versions_info = [{'min': None, 'max': '2.1', 'schema': schemav21},
{'min': '2.2', 'max': None, 'schema': schemav22}] {'min': '2.2', 'max': None, 'schema': schemav22}]
def __init__(self, auth_provider, service, region,
ssh_key_type='rsa', **kwargs):
super(KeyPairsClient, self).__init__(
auth_provider, service, region, **kwargs)
self.ssh_key_type = ssh_key_type
def list_keypairs(self, **params): def list_keypairs(self, **params):
"""Lists keypairs that are associated with the account. """Lists keypairs that are associated with the account.
@ -67,12 +77,30 @@ class KeyPairsClient(base_compute_client.BaseComputeClient):
API reference: API reference:
https://docs.openstack.org/api-ref/compute/#create-or-import-keypair https://docs.openstack.org/api-ref/compute/#create-or-import-keypair
""" """
pkey = None
if (self.ssh_key_type == 'ecdsa' and 'public_key' not in kwargs and
('type' not in kwargs or kwargs['type'] == 'ssh')):
# create a ecdsa key and pass the public key into the request
pkey = ec.generate_private_key(ec.SECP384R1(), default_backend())
pubkey = pkey.public_key().public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH)
kwargs['public_key'] = pubkey
post_body = json.dumps({'keypair': kwargs}) post_body = json.dumps({'keypair': kwargs})
resp, body = self.post("os-keypairs", body=post_body) resp, body = self.post("os-keypairs", body=post_body)
body = json.loads(body) body = json.loads(body)
schema = self.get_schema(self.schema_versions_info) schema = self.get_schema(self.schema_versions_info)
self.validate_response(schema.create_keypair, resp, body) self.validate_response(schema.create_keypair, resp, body)
return rest_client.ResponseBody(resp, body) resp_body = rest_client.ResponseBody(resp, body)
if pkey:
# add the privkey to the response as it was generated here
privkey = pkey.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption())
resp_body['keypair']['private_key'] = privkey.decode('utf-8')
return resp_body
def delete_keypair(self, keypair_name, **params): def delete_keypair(self, keypair_name, **params):
"""Deletes a keypair. """Deletes a keypair.

View File

@ -295,6 +295,22 @@
devstack_localrc: devstack_localrc:
TEMPEST_VOLUME_TYPE: volumev2 TEMPEST_VOLUME_TYPE: volumev2
- job:
name: tempest-centos8-stream-fips
parent: devstack-tempest
description: |
Integration testing for a FIPS enabled Centos 8 system
nodeset: devstack-single-node-centos-8-stream
pre-run: playbooks/enable-fips.yaml
vars:
tox_envlist: full
configure_swap_size: 4096
devstack_local_conf:
test-config:
"$TEMPEST_CONFIG":
validation:
ssh_key_type: 'ecdsa'
- job: - job:
name: tempest-pg-full name: tempest-pg-full
parent: tempest-full-py3 parent: tempest-full-py3

View File

@ -161,6 +161,8 @@
irrelevant-files: *tempest-irrelevant-files irrelevant-files: *tempest-irrelevant-files
- tempest-full-py3-opensuse15: - tempest-full-py3-opensuse15:
irrelevant-files: *tempest-irrelevant-files irrelevant-files: *tempest-irrelevant-files
- tempest-centos8-stream-fips:
irrelevant-files: *tempest-irrelevant-files
periodic-stable: periodic-stable:
jobs: jobs:
- tempest-full-xena - tempest-full-xena