From 80aae8fcf45fdc38fcb6c9fea503cecbe42e42b6 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Tue, 24 Jun 2014 15:33:23 +0300 Subject: [PATCH] Adds x509 certificate keypair support 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 --- nova/api/metadata/base.py | 11 +++++ nova/compute/api.py | 20 ++++++++-- nova/crypto.py | 51 ++++++++++++++++++++++++ nova/objects/keypair.py | 1 + nova/tests/unit/api/openstack/fakes.py | 6 +-- nova/tests/unit/compute/test_keypairs.py | 28 +++++++++++-- nova/tests/unit/fake_crypto.py | 25 ++++++++++++ nova/tests/unit/test_metadata.py | 26 +++++++++++- 8 files changed, 157 insertions(+), 11 deletions(-) diff --git a/nova/api/metadata/base.py b/nova/api/metadata/base.py index 86fed82ba8b..382378f347f 100644 --- a/nova/api/metadata/base.py +++ b/nova/api/metadata/base.py @@ -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 @@ -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 diff --git a/nova/compute/api.py b/nova/compute/api.py index d5fe40a2d7c..97ed8a1783a 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -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) @@ -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 @@ -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 @@ -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.""" diff --git a/nova/crypto.py b/nova/crypto.py index 8f68b4b7d92..d358681cab8 100644 --- a/nova/crypto.py +++ b/nova/crypto.py @@ -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') @@ -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( diff --git a/nova/objects/keypair.py b/nova/objects/keypair.py index aca274cb773..18b27adb6e3 100644 --- a/nova/objects/keypair.py +++ b/nova/objects/keypair.py @@ -20,6 +20,7 @@ from nova import utils KEYPAIR_TYPE_SSH = 'ssh' +KEYPAIR_TYPE_X509 = 'x509' # TODO(berrange): Remove NovaObjectDictCompat diff --git a/nova/tests/unit/api/openstack/fakes.py b/nova/tests/unit/api/openstack/fakes.py index 2819e1f1ef7..347a539c732 100644 --- a/nova/tests/unit/api/openstack/fakes.py +++ b/nova/tests/unit/api/openstack/fakes.py @@ -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) diff --git a/nova/tests/unit/compute/test_keypairs.py b/nova/tests/unit/compute/test_keypairs.py index c40f8bcc937..6ad7e6fa0d6 100644 --- a/nova/tests/unit/compute/test_keypairs.py +++ b/nova/tests/unit/compute/test_keypairs.py @@ -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 @@ -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, diff --git a/nova/tests/unit/fake_crypto.py b/nova/tests/unit/fake_crypto.py index cac79a36bc1..91d6f56787b 100644 --- a/nova/tests/unit/fake_crypto.py +++ b/nova/tests/unit/fake_crypto.py @@ -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 diff --git a/nova/tests/unit/test_metadata.py b/nova/tests/unit/test_metadata.py index a9f173885bb..efdfd2031b4 100644 --- a/nova/tests/unit/test_metadata.py +++ b/nova/tests/unit/test_metadata.py @@ -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 @@ -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, @@ -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(): @@ -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"), @@ -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}} @@ -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) @@ -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( @@ -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']