From 0c5273c85ea72d60e5907acb22398584ded0a077 Mon Sep 17 00:00:00 2001 From: Vishvananda Ishaya Date: Wed, 18 Jan 2012 21:04:47 -0800 Subject: [PATCH] Create nova cert worker for x509 support * Adds new worker for cert management * Makes decrypt use an rpc to the worker * Moves CA filesystem creation out of cloud.setup * Moves test for X509 into crypto * Adds test for encrypting and decrypting using cert * Cleans up extra code in cloudpipe * Fixes bug 918563 * Prepares for a future patch that will fix bug 903345 Change-Id: I4693c50c8f432706f97395af39e736f49d60e719 --- bin/nova-all | 2 +- bin/nova-cert | 47 +++++++++++++ nova/api/ec2/cloud.py | 25 ------- .../openstack/compute/contrib/cloudpipe.py | 23 +------ nova/cert/__init__.py | 15 +++++ nova/cert/manager.py | 67 +++++++++++++++++++ nova/crypto.py | 44 ++++++++++++ nova/exception.py | 4 ++ nova/flags.py | 3 + nova/image/s3.py | 65 +++++++++--------- nova/tests/test_auth.py | 23 ------- nova/tests/test_crypto.py | 65 +++++++++++++++--- setup.py | 1 + 13 files changed, 271 insertions(+), 113 deletions(-) create mode 100755 bin/nova-cert create mode 100644 nova/cert/__init__.py create mode 100644 nova/cert/manager.py diff --git a/bin/nova-all b/bin/nova-all index 9c9e2bbaab63..806c2d0027b6 100755 --- a/bin/nova-all +++ b/bin/nova-all @@ -65,7 +65,7 @@ if __name__ == '__main__': except (Exception, SystemExit): logging.exception(_('Failed to load %s') % 'objectstore-wsgi') for binary in ['nova-xvpvncproxy', 'nova-compute', 'nova-volume', - 'nova-network', 'nova-scheduler', 'nova-vsa']: + 'nova-network', 'nova-scheduler', 'nova-vsa', 'nova-cert']: try: servers.append(service.Service.create(binary=binary)) except (Exception, SystemExit): diff --git a/bin/nova-cert b/bin/nova-cert new file mode 100755 index 000000000000..725bf4aee1ef --- /dev/null +++ b/bin/nova-cert @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Starter script for Nova Cert.""" + +import eventlet +eventlet.monkey_patch() + +import os +import sys + +# If ../nova/__init__.py exists, add ../ to Python search path, so that +# it will override what happens to be installed in /usr/(local/)lib/python... +POSSIBLE_TOPDIR = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), + os.pardir, + os.pardir)) +if os.path.exists(os.path.join(POSSIBLE_TOPDIR, 'nova', '__init__.py')): + sys.path.insert(0, POSSIBLE_TOPDIR) + + +from nova import flags +from nova import log as logging +from nova import service +from nova import utils + +if __name__ == '__main__': + utils.default_flagfile() + flags.FLAGS(sys.argv) + logging.setup() + utils.monkey_patch() + server = service.Service.create(binary='nova-cert') + service.serve(server) + service.wait() diff --git a/nova/api/ec2/cloud.py b/nova/api/ec2/cloud.py index eb115d8dc1a1..5a3b952a9796 100644 --- a/nova/api/ec2/cloud.py +++ b/nova/api/ec2/cloud.py @@ -205,35 +205,10 @@ class CloudController(object): self.volume_api = volume.API() self.compute_api = compute.API(network_api=self.network_api, volume_api=self.volume_api) - self.setup() def __str__(self): return 'CloudController' - def setup(self): - """ Ensure the keychains and folders exist. """ - # FIXME(ja): this should be moved to a nova-manage command, - # if not setup throw exceptions instead of running - # Create keys folder, if it doesn't exist - if not os.path.exists(FLAGS.keys_path): - os.makedirs(FLAGS.keys_path) - # Gen root CA, if we don't have one - root_ca_path = os.path.join(FLAGS.ca_path, FLAGS.ca_file) - if not os.path.exists(root_ca_path): - genrootca_sh_path = os.path.join(os.path.dirname(__file__), - os.path.pardir, - os.path.pardir, - 'CA', - 'genrootca.sh') - - start = os.getcwd() - if not os.path.exists(FLAGS.ca_path): - os.makedirs(FLAGS.ca_path) - os.chdir(FLAGS.ca_path) - # TODO(vish): Do this with M2Crypto instead - utils.runthis(_("Generating root CA: %s"), "sh", genrootca_sh_path) - os.chdir(start) - def _get_image_state(self, image): # NOTE(vish): fallback status if image_state isn't set state = image.get('status') diff --git a/nova/api/openstack/compute/contrib/cloudpipe.py b/nova/api/openstack/compute/contrib/cloudpipe.py index 1cf47a2a9208..9d944366b9d3 100644 --- a/nova/api/openstack/compute/contrib/cloudpipe.py +++ b/nova/api/openstack/compute/contrib/cloudpipe.py @@ -60,28 +60,11 @@ class CloudpipeController(object): def setup(self): """Ensure the keychains and folders exist.""" - # TODO(todd): this was copyed from api.ec2.cloud - # FIXME(ja): this should be moved to a nova-manage command, - # if not setup throw exceptions instead of running - # Create keys folder, if it doesn't exist + # NOTE(vish): One of the drawbacks of doing this in the api is + # the keys will only be on the api node that launched + # the cloudpipe. if not os.path.exists(FLAGS.keys_path): os.makedirs(FLAGS.keys_path) - # Gen root CA, if we don't have one - root_ca_path = os.path.join(FLAGS.ca_path, FLAGS.ca_file) - if not os.path.exists(root_ca_path): - genrootca_sh_path = os.path.join(os.path.dirname(__file__), - os.path.pardir, - os.path.pardir, - 'CA', - 'genrootca.sh') - - start = os.getcwd() - if not os.path.exists(FLAGS.ca_path): - os.makedirs(FLAGS.ca_path) - os.chdir(FLAGS.ca_path) - # TODO(vish): Do this with M2Crypto instead - utils.runthis(_("Generating root CA: %s"), "sh", genrootca_sh_path) - os.chdir(start) def _get_cloudpipe_for_project(self, context, project_id): """Get the cloudpipe instance for a project ID.""" diff --git a/nova/cert/__init__.py b/nova/cert/__init__.py new file mode 100644 index 000000000000..74cec69384b3 --- /dev/null +++ b/nova/cert/__init__.py @@ -0,0 +1,15 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/nova/cert/manager.py b/nova/cert/manager.py new file mode 100644 index 000000000000..ee16dc8a949c --- /dev/null +++ b/nova/cert/manager.py @@ -0,0 +1,67 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +Cert manager manages x509 certificates. + +**Related Flags** +:cert_topic: What :mod:`rpc` topic to listen to (default: `cert`). +:cert_manager: The module name of a class derived from + :class:`manager.Manager` (default: + :class:`nova.cert.manager.Manager`). +""" + +import base64 + +from nova import crypto +from nova import flags +from nova import log as logging +from nova import manager + +LOG = logging.getLogger('nova.cert.manager') +FLAGS = flags.FLAGS + + +class CertManager(manager.Manager): + def init_host(self): + crypto.ensure_ca_filesystem() + + def revoke_certs_by_user(self, context, user_id): + """Revoke all user certs.""" + return crypto.revoke_certs_by_user(user_id) + + def revoke_certs_by_project(self, context, project_id): + """Revoke all project certs.""" + return crypto.revoke_certs_by_project(project_id) + + def revoke_certs_by_user_and_project(self, context, user_id, project_id): + """Revoke certs for user in project.""" + return crypto.revoke_certs_by_user_and_project(project_id) + + def generate_x509_cert(self, context, user_id, project_id): + """Generate and sign a cert for user in project""" + return crypto.generate_x509_cert(user_id, project_id) + + def fetch_ca(self, context, project_id): + """Get root ca for a project""" + return crypto.fetch_ca(project_id) + + def fetch_crl(self, context, project_id): + """Get crl for a project""" + return crypto.fetch_ca(project_id) + + def decrypt_text(self, context, project_id, text): + """Decrypt base64 encoded text using the projects private key.""" + return crypto.decrypt_text(project_id, base64.b64decode(text)) diff --git a/nova/crypto.py b/nova/crypto.py index 0ddc9e8e7aa6..c0bb750f5a02 100644 --- a/nova/crypto.py +++ b/nova/crypto.py @@ -39,6 +39,7 @@ gettext.install('nova', unicode=1) from nova import context from nova import db +from nova import exception from nova import flags from nova import log as logging @@ -85,6 +86,10 @@ def key_path(project_id=None): return os.path.join(ca_folder(project_id), FLAGS.key_file) +def crl_path(project_id=None): + return os.path.join(ca_folder(project_id), FLAGS.crl_file) + + def fetch_ca(project_id=None): if not FLAGS.use_project_ca: project_id = None @@ -92,6 +97,22 @@ def fetch_ca(project_id=None): return cafile.read() +def ensure_ca_filesystem(): + """Ensure the CA filesystem exists.""" + ca_dir = ca_folder() + if not os.path.exists(ca_path()): + genrootca_sh_path = os.path.join(os.path.dirname(__file__), + 'CA', + 'genrootca.sh') + + start = os.getcwd() + if not os.path.exists(ca_dir): + os.makedirs(ca_dir) + os.chdir(ca_dir) + utils.runthis(_("Generating root CA: %s"), "sh", genrootca_sh_path) + os.chdir(start) + + def _generate_fingerprint(public_key_file): (out, err) = utils.execute('ssh-keygen', '-q', '-l', '-f', public_key_file) fingerprint = out.split(' ')[1] @@ -148,6 +169,29 @@ def ssl_pub_to_ssh_pub(ssl_public_key, name='root', suffix='nova'): return '%s %s %s@%s\n' % (key_type, b64_blob, name, suffix) +def fetch_crl(project_id): + """Get crl file for project.""" + if not FLAGS.use_project_ca: + project_id = None + with open(crl_path(project_id), 'r') as crlfile: + return crlfile.read() + + +def decrypt_text(project_id, text): + private_key = key_path(project_id) + if not os.path.exists(private_key): + raise exception.ProjectNotFound(project_id=project_id) + try: + dec, _err = utils.execute('openssl', + 'rsautl', + '-decrypt', + '-inkey', '%s' % private_key, + process_input=text) + return dec + except exception.ProcessExecutionError: + raise exception.DecryptionFailure() + + def revoke_cert(project_id, file_name): """Revoke a cert by file name.""" start = os.getcwd() diff --git a/nova/exception.py b/nova/exception.py index 8eda6bfc686d..1478340660ce 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -179,6 +179,10 @@ class NovaException(Exception): super(NovaException, self).__init__(message) +class DecryptionFailure(NovaException): + message = _("Failed to decrypt text") + + class ImagePaginationFailed(NovaException): message = _("Failed to paginate through images from image service") diff --git a/nova/flags.py b/nova/flags.py index 855ef8ed0e89..d535f783f92a 100644 --- a/nova/flags.py +++ b/nova/flags.py @@ -275,6 +275,7 @@ DEFINE_integer('glance_num_retries', 0, DEFINE_integer('s3_port', 3333, 's3 port') DEFINE_string('s3_host', '$my_ip', 's3 host (for infrastructure)') DEFINE_string('s3_dmz', '$my_ip', 's3 dmz ip (for instances)') +DEFINE_string('cert_topic', 'cert', 'the topic cert nodes listen on') DEFINE_string('compute_topic', 'compute', 'the topic compute nodes listen on') DEFINE_string('console_topic', 'console', 'the topic console proxy nodes listen on') @@ -367,6 +368,8 @@ DEFINE_string('compute_manager', 'nova.compute.manager.ComputeManager', 'Manager for compute') DEFINE_string('console_manager', 'nova.console.manager.ConsoleProxyManager', 'Manager for console proxy') +DEFINE_string('cert_manager', 'nova.cert.manager.CertManager', + 'Manager for cert') DEFINE_string('instance_dns_manager', 'nova.network.dns_driver.DNSDriver', 'DNS Manager for instance IPs') diff --git a/nova/image/s3.py b/nova/image/s3.py index fe320a406099..e73364a4295b 100644 --- a/nova/image/s3.py +++ b/nova/image/s3.py @@ -18,6 +18,7 @@ """Proxy AMI-related calls from cloud controller to objectstore service.""" +import base64 import binascii import os import shutil @@ -28,7 +29,7 @@ from xml.etree import ElementTree import boto.s3.connection import eventlet -from nova import crypto +from nova import rpc import nova.db.api from nova import exception from nova import flags @@ -302,14 +303,9 @@ class S3ImageService(object): hex_iv = manifest.find('image/ec2_encrypted_iv').text encrypted_iv = binascii.a2b_hex(hex_iv) - # FIXME(vish): grab key from common service so this can run on - # any host. - cloud_pk = crypto.key_path(context.project_id) - dec_filename = os.path.join(image_path, 'image.tar.gz') - self._decrypt_image(enc_filename, encrypted_key, - encrypted_iv, cloud_pk, - dec_filename) + self._decrypt_image(context, enc_filename, encrypted_key, + encrypted_iv, dec_filename) except Exception: LOG.exception(_("Failed to decrypt %(image_location)s " "to %(image_path)s"), log_vars) @@ -353,39 +349,38 @@ class S3ImageService(object): return image @staticmethod - def _decrypt_image(encrypted_filename, encrypted_key, encrypted_iv, - cloud_private_key, decrypted_filename): - key, err = utils.execute('openssl', - 'rsautl', - '-decrypt', - '-inkey', '%s' % cloud_private_key, - process_input=encrypted_key, - check_exit_code=False) - if err: + def _decrypt_image(context, encrypted_filename, encrypted_key, + encrypted_iv, decrypted_filename): + elevated = context.elevated() + try: + key = rpc.call(elevated, FLAGS.cert_topic, + {"method": "decrypt_text", + "args": {"project_id": context.project_id, + "text": base64.b64encode(encrypted_key)}}) + except Exception, exc: raise exception.Error(_('Failed to decrypt private key: %s') - % err) - iv, err = utils.execute('openssl', - 'rsautl', - '-decrypt', - '-inkey', '%s' % cloud_private_key, - process_input=encrypted_iv, - check_exit_code=False) - if err: + % exc) + try: + iv = rpc.call(elevated, FLAGS.cert_topic, + {"method": "decrypt_text", + "args": {"project_id": context.project_id, + "text": base64.b64encode(encrypted_iv)}}) + except Exception, exc: raise exception.Error(_('Failed to decrypt initialization ' - 'vector: %s') % err) + 'vector: %s') % exc) - _out, err = utils.execute('openssl', 'enc', - '-d', '-aes-128-cbc', - '-in', '%s' % (encrypted_filename,), - '-K', '%s' % (key,), - '-iv', '%s' % (iv,), - '-out', '%s' % (decrypted_filename,), - check_exit_code=False) - if err: + try: + utils.execute('openssl', 'enc', + '-d', '-aes-128-cbc', + '-in', '%s' % (encrypted_filename,), + '-K', '%s' % (key,), + '-iv', '%s' % (iv,), + '-out', '%s' % (decrypted_filename,)) + except exception.ProcessExecutionError, exc: raise exception.Error(_('Failed to decrypt image file ' '%(image_file)s: %(err)s') % {'image_file': encrypted_filename, - 'err': err}) + 'err': exc.stdout}) @staticmethod def _test_for_malicious_tarball(path, filename): diff --git a/nova/tests/test_auth.py b/nova/tests/test_auth.py index b1feb08567ac..bdc7f3142b4a 100644 --- a/nova/tests/test_auth.py +++ b/nova/tests/test_auth.py @@ -16,7 +16,6 @@ # License for the specific language governing permissions and limitations # under the License. -from M2Crypto import X509 import unittest from nova import crypto @@ -245,28 +244,6 @@ class _AuthManagerBaseTestCase(test.TestCase): project)) self.assertFalse(self.manager.is_project_member(user, project)) - def test_can_generate_x509(self): - # NOTE(todd): this doesn't assert against the auth manager - # so it probably belongs in crypto_unittest - # but I'm leaving it where I found it. - with user_and_project_generator(self.manager) as (user, project): - # NOTE(vish): Setup runs genroot.sh if it hasn't been run - cloud.CloudController().setup() - _key, cert_str = crypto.generate_x509_cert(user.id, project.id) - LOG.debug(cert_str) - - int_cert = crypto.fetch_ca(project_id=project.id) - cloud_cert = crypto.fetch_ca() - signed_cert = X509.load_cert_string(cert_str) - int_cert = X509.load_cert_string(int_cert) - cloud_cert = X509.load_cert_string(cloud_cert) - self.assertTrue(signed_cert.verify(int_cert.get_pubkey())) - - if not FLAGS.use_project_ca: - self.assertTrue(signed_cert.verify(cloud_cert.get_pubkey())) - else: - self.assertFalse(signed_cert.verify(cloud_cert.get_pubkey())) - def test_adding_role_to_project_is_ignored_unless_added_to_user(self): with user_and_project_generator(self.manager) as (user, project): self.assertFalse(self.manager.has_role(user, 'sysadmin', project)) diff --git a/nova/tests/test_crypto.py b/nova/tests/test_crypto.py index 6c25b396e4b6..b5f70ea72a0e 100644 --- a/nova/tests/test_crypto.py +++ b/nova/tests/test_crypto.py @@ -16,12 +16,20 @@ Tests for Crypto module. """ +import os +import shutil +import tempfile + import mox -import stubout +from M2Crypto import X509 from nova import crypto from nova import db +from nova import flags from nova import test +from nova import utils + +FLAGS = flags.FLAGS class SymmetricKeyTestCase(test.TestCase): @@ -52,16 +60,55 @@ class SymmetricKeyTestCase(test.TestCase): self.assertEquals(plain_text, plain) +class X509Test(test.TestCase): + def test_can_generate_x509(self): + tmpdir = tempfile.mkdtemp() + self.flags(ca_path=tmpdir) + try: + crypto.ensure_ca_filesystem() + _key, cert_str = crypto.generate_x509_cert('fake', 'fake') + + project_cert = crypto.fetch_ca(project_id='fake') + cloud_cert = crypto.fetch_ca() + # TODO(vish): This will need to be replaced with something else + # when we remove M2Crypto + signed_cert = X509.load_cert_string(cert_str) + project_cert = X509.load_cert_string(project_cert) + cloud_cert = X509.load_cert_string(cloud_cert) + self.assertTrue(signed_cert.verify(project_cert.get_pubkey())) + + if not FLAGS.use_project_ca: + self.assertTrue(signed_cert.verify(cloud_cert.get_pubkey())) + else: + self.assertFalse(signed_cert.verify(cloud_cert.get_pubkey())) + finally: + shutil.rmtree(tmpdir) + + def test_encrypt_decrypt_x509(self): + tmpdir = tempfile.mkdtemp() + self.flags(ca_path=tmpdir) + project_id = "fake" + try: + crypto.ensure_ca_filesystem() + cert = crypto.fetch_ca(project_id) + public_key = os.path.join(tmpdir, "public.pem") + with open(public_key, 'w') as keyfile: + keyfile.write(cert) + text = "some @#!%^* test text" + enc, _err = utils.execute('openssl', + 'rsautl', + '-certin', + '-encrypt', + '-inkey', '%s' % public_key, + process_input=text) + dec = crypto.decrypt_text(project_id, enc) + self.assertEqual(text, dec) + finally: + shutil.rmtree(tmpdir) + + class RevokeCertsTest(test.TestCase): - def setUp(self): - super(RevokeCertsTest, self).setUp() - self.stubs = stubout.StubOutForTesting() - - def tearDown(self): - self.stubs.UnsetAll() - super(RevokeCertsTest, self).tearDown() - def test_revoke_certs_by_user_and_project(self): user_id = 'test_user' project_id = 2 diff --git a/setup.py b/setup.py index b63bfde39ac9..d98918968f40 100644 --- a/setup.py +++ b/setup.py @@ -89,6 +89,7 @@ setup(name='nova', 'bin/nova-api-metadata', 'bin/nova-api-os-compute', 'bin/nova-api-os-volume', + 'bin/nova-cert', 'bin/nova-compute', 'bin/nova-console', 'bin/nova-consoleauth',