Skip to content

Commit

Permalink
Adds x509 certificate keypair support
Browse files Browse the repository at this point in the history
X509 certificates are used by Windows for passwordless
authentication (WinRM) in a way which can be considered
consistent with the usage of SSH keys on Linux, as both
are based on public / private keypairs.

Adds x509 certificate creation implementation.
Adds 'keys' in InstanceMetadata.

Implements: blueprint keypair-x509-certificates

Depends-On: Id5b210d7afe5c0a590abcbd42b9ff85b071a5c55

Change-Id: I1ab518553cf757ae35548fa89b6c8fee7ec32b8d
  • Loading branch information
claudiubelu committed Mar 5, 2015
1 parent 6b3ebfd commit 80aae8f
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 11 deletions.
11 changes: 11 additions & 0 deletions nova/api/metadata/base.py
Expand Up @@ -35,6 +35,7 @@
from nova import network
from nova import objects
from nova.objects import base as obj_base
from nova.objects import keypair as keypair_obj
from nova import utils
from nova.virt import netutils

Expand Down Expand Up @@ -309,6 +310,16 @@ def _metadata_as_json(self, version, path):
metadata['public_keys'] = {
self.instance.key_name: self.instance.key_data
}

keypair = keypair_obj.KeyPair.get_by_name(
context.get_admin_context(), self.instance.user_id,
self.instance.key_name)
metadata['keys'] = [
{'name': keypair.name,
'type': keypair.type,
'data': keypair.public_key}
]

metadata['hostname'] = self._get_hostname()
metadata['name'] = self.instance.display_name
metadata['launch_index'] = self.instance.launch_index
Expand Down
20 changes: 17 additions & 3 deletions nova/compute/api.py
Expand Up @@ -3731,7 +3731,8 @@ def _notify(self, context, event_suffix, keypair_name):
notify.info(context, 'keypair.%s' % event_suffix, payload)

def _validate_new_key_pair(self, context, user_id, key_name, key_type):
if key_type is not keypair_obj.KEYPAIR_TYPE_SSH:
if key_type not in [keypair_obj.KEYPAIR_TYPE_SSH,
keypair_obj.KEYPAIR_TYPE_X509]:
raise exception.InvalidKeypair(
reason=_('Specified Keypair type "%s" is invalid') % key_type)

Expand Down Expand Up @@ -3763,7 +3764,7 @@ def import_key_pair(self, context, user_id, key_name, public_key,

self._notify(context, 'import.start', key_name)

fingerprint = crypto.generate_fingerprint(public_key)
fingerprint = self._generate_fingerprint(public_key, key_type)

keypair = objects.KeyPair(context)
keypair.user_id = user_id
Expand All @@ -3785,7 +3786,8 @@ def create_key_pair(self, context, user_id, key_name,

self._notify(context, 'create.start', key_name)

private_key, public_key, fingerprint = crypto.generate_key_pair()
private_key, public_key, fingerprint = self._generate_key_pair(
context, user_id, key_type)

keypair = objects.KeyPair(context)
keypair.user_id = user_id
Expand All @@ -3799,6 +3801,18 @@ def create_key_pair(self, context, user_id, key_name,

return keypair, private_key

def _generate_fingerprint(self, public_key, key_type):
if key_type == keypair_obj.KEYPAIR_TYPE_SSH:
return crypto.generate_fingerprint(public_key)
elif key_type == keypair_obj.KEYPAIR_TYPE_X509:
return crypto.generate_x509_fingerprint(public_key)

def _generate_key_pair(self, context, user_id, key_type):
if key_type == keypair_obj.KEYPAIR_TYPE_SSH:
return crypto.generate_key_pair()
elif key_type == keypair_obj.KEYPAIR_TYPE_X509:
return crypto.generate_winrm_x509_cert(user_id, context.project_id)

@wrap_exception()
def delete_key_pair(self, context, user_id, key_name):
"""Delete a keypair by name."""
Expand Down
51 changes: 51 additions & 0 deletions nova/crypto.py
Expand Up @@ -144,6 +144,19 @@ def generate_fingerprint(public_key):
reason=_('failed to generate fingerprint'))


def generate_x509_fingerprint(pem_key):
try:
(out, _err) = utils.execute('openssl', 'x509', '-inform', 'PEM',
'-fingerprint', '-noout',
process_input=pem_key)
fingerprint = string.strip(out.rpartition('=')[2])
return fingerprint
except processutils.ProcessExecutionError as ex:
raise exception.InvalidKeypair(
reason=_('failed to generate X509 fingerprint. '
'Error message: %s') % ex)


def generate_key_pair(bits=None):
with utils.tempdir() as tmpdir:
keyfile = os.path.join(tmpdir, 'temp')
Expand Down Expand Up @@ -352,6 +365,44 @@ def generate_x509_cert(user_id, project_id, bits=2048):
return (private_key, signed_csr)


def generate_winrm_x509_cert(user_id, project_id, bits=2048):
"""Generate a cert for passwordless auth for user in project."""
subject = '/CN=%s-%s' % (project_id, user_id)
upn = '%s@localhost' % user_id

with utils.tempdir() as tmpdir:
keyfile = os.path.abspath(os.path.join(tmpdir, 'temp.key'))
conffile = os.path.abspath(os.path.join(tmpdir, 'temp.conf'))

_create_x509_openssl_config(conffile, upn)

(certificate, _err) = utils.execute(
'openssl', 'req', '-x509', '-nodes', '-days', '3650',
'-config', conffile, '-newkey', 'rsa:%s' % bits,
'-outform', 'PEM', '-keyout', keyfile, '-subj', subject,
'-extensions', 'v3_req_client')

(out, _err) = utils.execute('openssl', 'pkcs12', '-export',
'-inkey', keyfile, '-password', 'pass:',
process_input=certificate)

private_key = out.encode('base64')
fingerprint = generate_x509_fingerprint(certificate)

return (private_key, certificate, fingerprint)


def _create_x509_openssl_config(conffile, upn):
content = ("distinguished_name = req_distinguished_name\n"
"[req_distinguished_name]\n"
"[v3_req_client]\n"
"extendedKeyUsage = clientAuth\n"
"subjectAltName = otherName:""1.3.6.1.4.1.311.20.2.3;UTF8:%s\n")

with open(conffile, 'w') as file:
file.write(content % upn)


def _ensure_project_folder(project_id):
if not os.path.exists(ca_path(project_id)):
geninter_sh_path = os.path.abspath(
Expand Down
1 change: 1 addition & 0 deletions nova/objects/keypair.py
Expand Up @@ -20,6 +20,7 @@
from nova import utils

KEYPAIR_TYPE_SSH = 'ssh'
KEYPAIR_TYPE_X509 = 'x509'


# TODO(berrange): Remove NovaObjectDictCompat
Expand Down
6 changes: 3 additions & 3 deletions nova/tests/unit/api/openstack/fakes.py
Expand Up @@ -123,15 +123,15 @@ def wsgi_app_v21(inner_app_v21=None, fake_auth_context=None,
return mapper


def stub_out_key_pair_funcs(stubs, have_key_pair=True):
def stub_out_key_pair_funcs(stubs, have_key_pair=True, **kwargs):
def key_pair(context, user_id):
return [dict(test_keypair.fake_keypair,
name='key', public_key='public_key')]
name='key', public_key='public_key', **kwargs)]

def one_key_pair(context, user_id, name):
if name == 'key':
return dict(test_keypair.fake_keypair,
name='key', public_key='public_key')
name='key', public_key='public_key', **kwargs)
else:
raise exc.KeypairNotFound(user_id=user_id, name=name)

Expand Down
28 changes: 24 additions & 4 deletions nova/tests/unit/compute/test_keypairs.py
Expand Up @@ -24,6 +24,7 @@
from nova.objects import keypair as keypair_obj
from nova import quota
from nova.tests.unit.compute import test_compute
from nova.tests.unit import fake_crypto
from nova.tests.unit import fake_notifier
from nova.tests.unit.objects import test_keypair

Expand Down Expand Up @@ -169,29 +170,48 @@ def fake_quotas_count(self, context, resource, *args, **kwargs):
class CreateKeypairTestCase(KeypairAPITestCase, CreateImportSharedTestMixIn):
func_name = 'create_key_pair'

def test_success(self):
def _check_success(self):
keypair, private_key = self.keypair_api.create_key_pair(
self.ctxt, self.ctxt.user_id, 'foo')
self.ctxt, self.ctxt.user_id, 'foo', key_type=self.keypair_type)
self.assertEqual('foo', keypair['name'])
self.assertEqual(self.keypair_type, keypair['type'])
self._check_notifications()

def test_success_ssh(self):
self._check_success()

def test_success_x509(self):
self.keypair_type = keypair_obj.KEYPAIR_TYPE_X509
self._check_success()


class ImportKeypairTestCase(KeypairAPITestCase, CreateImportSharedTestMixIn):
func_name = 'import_key_pair'

def test_success(self):
def _check_success(self):
keypair = self.keypair_api.import_key_pair(self.ctxt,
self.ctxt.user_id,
'foo',
self.pub_key)
self.pub_key,
self.keypair_type)

self.assertEqual('foo', keypair['name'])
self.assertEqual(self.keypair_type, keypair['type'])
self.assertEqual(self.fingerprint, keypair['fingerprint'])
self.assertEqual(self.pub_key, keypair['public_key'])
self.assertEqual(self.keypair_type, keypair['type'])
self._check_notifications(action='import')

def test_success_ssh(self):
self._check_success()

def test_success_x509(self):
self.keypair_type = keypair_obj.KEYPAIR_TYPE_X509
certif, fingerprint = fake_crypto.get_x509_cert_and_fingerprint()
self.pub_key = certif
self.fingerprint = fingerprint
self._check_success()

def test_bad_key_data(self):
exc = self.assertRaises(exception.InvalidKeypair,
self.keypair_api.import_key_pair,
Expand Down
25 changes: 25 additions & 0 deletions nova/tests/unit/fake_crypto.py
Expand Up @@ -107,3 +107,28 @@ def generate_x509_cert(user_id, project_id, bits=1024):
-----END CERTIFICATE-----
"""
return pk, csr


def get_x509_cert_and_fingerprint():
fingerprint = "A1:6F:6D:EA:A6:36:D0:3A:C6:EB:B6:EE:07:94:3E:2A:90:98:2B:C9"
certif = (
"-----BEGIN CERTIFICATE-----\n"
"MIIDIjCCAgqgAwIBAgIJAIE8EtWfZhhFMA0GCSqGSIb3DQEBCwUAMCQxIjAgBgNV\n"
"BAMTGWNsb3VkYmFzZS1pbml0LXVzZXItMTM1NTkwHhcNMTUwMTI5MTgyMzE4WhcN\n"
"MjUwMTI2MTgyMzE4WjAkMSIwIAYDVQQDExljbG91ZGJhc2UtaW5pdC11c2VyLTEz\n"
"NTU5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv4lv95ofkXLIbALU\n"
"UEb1f949TYNMUvMGNnLyLgGOY+D61TNG7RZn85cRg9GVJ7KDjSLN3e3LwH5rgv5q\n"
"pU+nM/idSMhG0CQ1lZeExTsMEJVT3bG7LoU5uJ2fJSf5+hA0oih2M7/Kap5ggHgF\n"
"h+h8MWvDC9Ih8x1aadkk/OEmJsTrziYm0C/V/FXPHEuXfZn8uDNKZ/tbyfI6hwEj\n"
"nLz5Zjgg29n6tIPYMrnLNDHScCwtNZOcnixmWzsxCt1bxsAEA/y9gXUT7xWUf52t\n"
"2+DGQbLYxo0PHjnPf3YnFXNavfTt+4c7ZdHhOQ6ZA8FGQ2LJHDHM1r2/8lK4ld2V\n"
"qgNTcQIDAQABo1cwVTATBgNVHSUEDDAKBggrBgEFBQcDAjA+BgNVHREENzA1oDMG\n"
"CisGAQQBgjcUAgOgJQwjY2xvdWRiYXNlLWluaXQtdXNlci0xMzU1OUBsb2NhbGhv\n"
"c3QwDQYJKoZIhvcNAQELBQADggEBAHHX/ZUOMR0ZggQnfXuXLIHWlffVxxLOV/bE\n"
"7JC/dtedHqi9iw6sRT5R6G1pJo0xKWr2yJVDH6nC7pfxCFkby0WgVuTjiu6iNRg2\n"
"4zNJd8TGrTU+Mst+PPJFgsxrAY6vjwiaUtvZ/k8PsphHXu4ON+oLurtVDVgog7Vm\n"
"fQCShx434OeJj1u8pb7o2WyYS5nDVrHBhlCAqVf2JPKu9zY+i9gOG2kimJwH7fJD\n"
"xXpMIwAQ+flwlHR7OrE0L8TNcWwKPRAY4EPcXrT+cWo1k6aTqZDSK54ygW2iWtni\n"
"ZBcstxwcB4GIwnp1DrPW9L2gw5eLe1Sl6wdz443TW8K/KPV9rWQ=\n"
"-----END CERTIFICATE-----\n")
return certif, fingerprint
26 changes: 25 additions & 1 deletion nova/tests/unit/test_metadata.py
Expand Up @@ -45,6 +45,7 @@
from nova.network import model as network_model
from nova import objects
from nova import test
from nova.tests.unit.api.openstack import fakes
from nova.tests.unit import fake_block_device
from nova.tests.unit import fake_network
from nova.tests.unit.objects import test_security_group
Expand All @@ -60,9 +61,10 @@ def fake_inst_obj(context):
inst = objects.Instance(
context=context,
id=1,
user_id='fake_user',
uuid='b65cee2f-8c69-4aeb-be2f-f79742548fc2',
project_id='test',
key_name="mykey",
key_name="key",
key_data="ssh-rsa AAAAB3Nzai....N3NtHw== someuser@somehost",
host='test',
launch_index=1,
Expand Down Expand Up @@ -300,6 +302,7 @@ def test_InstanceMetadata_uses_passed_network_info(self):
network_info=network_info)

def test_InstanceMetadata_invoke_metadata_for_config_drive(self):
fakes.stub_out_key_pair_funcs(self.stubs)
inst = self.instance.obj_clone()
inst_md = base.InstanceMetadata(inst)
for (path, value) in inst_md.metadata_for_config_drive():
Expand Down Expand Up @@ -396,6 +399,7 @@ def test_returns_apis_supported_in_grizzly_version(self):
grizzly_supported_apis)

def test_metadata_json(self):
fakes.stub_out_key_pair_funcs(self.stubs)
inst = self.instance.obj_clone()
content = [
('/etc/my.conf', "content of my.conf"),
Expand Down Expand Up @@ -429,8 +433,25 @@ def test_metadata_json(self):
found = mdinst.lookup("/openstack%s" % fent['content_path'])
self.assertEqual(found, content)

def test_x509_keypair(self):
# check if the x509 content is set, if the keypair type is x509.
fakes.stub_out_key_pair_funcs(self.stubs, type='x509')
inst = self.instance.obj_clone()
mdinst = fake_InstanceMetadata(self.stubs, inst)

mdjson = mdinst.lookup("/openstack/2012-08-10/meta_data.json")
mddict = jsonutils.loads(mdjson)

# keypair is stubbed-out, so it's public_key is 'public_key'.
expected = {'name': self.instance['key_name'],
'type': 'x509',
'data': 'public_key'}

self.assertEqual([expected], mddict['keys'])

def test_extra_md(self):
# make sure extra_md makes it through to metadata
fakes.stub_out_key_pair_funcs(self.stubs)
inst = self.instance.obj_clone()
extra = {'foo': 'bar', 'mylist': [1, 2, 3],
'mydict': {"one": 1, "two": 2}}
Expand Down Expand Up @@ -470,6 +491,7 @@ def test_userdata(self):
mdinst.lookup, "/openstack/2012-08-10/user_data")

def test_random_seed(self):
fakes.stub_out_key_pair_funcs(self.stubs)
inst = self.instance.obj_clone()
mdinst = fake_InstanceMetadata(self.stubs, inst)

Expand All @@ -486,6 +508,7 @@ def test_random_seed(self):

def test_no_dashes_in_metadata(self):
# top level entries in meta_data should not contain '-' in their name
fakes.stub_out_key_pair_funcs(self.stubs)
inst = self.instance.obj_clone()
mdinst = fake_InstanceMetadata(self.stubs, inst)
mdjson = jsonutils.loads(
Expand Down Expand Up @@ -592,6 +615,7 @@ def test_version_root(self):
self.assertEqual(response.status_int, 404)

def test_json_data(self):
fakes.stub_out_key_pair_funcs(self.stubs)
response = fake_request(self.stubs, self.mdinst,
"/openstack/latest/meta_data.json")
response_ctype = response.headers['Content-Type']
Expand Down

0 comments on commit 80aae8f

Please sign in to comment.