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
This commit is contained in:
Vishvananda Ishaya 2012-01-18 21:04:47 -08:00
parent 30a40db708
commit 0c5273c85e
13 changed files with 271 additions and 113 deletions

View File

@ -65,7 +65,7 @@ if __name__ == '__main__':
except (Exception, SystemExit): except (Exception, SystemExit):
logging.exception(_('Failed to load %s') % 'objectstore-wsgi') logging.exception(_('Failed to load %s') % 'objectstore-wsgi')
for binary in ['nova-xvpvncproxy', 'nova-compute', 'nova-volume', for binary in ['nova-xvpvncproxy', 'nova-compute', 'nova-volume',
'nova-network', 'nova-scheduler', 'nova-vsa']: 'nova-network', 'nova-scheduler', 'nova-vsa', 'nova-cert']:
try: try:
servers.append(service.Service.create(binary=binary)) servers.append(service.Service.create(binary=binary))
except (Exception, SystemExit): except (Exception, SystemExit):

47
bin/nova-cert Executable file
View File

@ -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()

View File

@ -205,35 +205,10 @@ class CloudController(object):
self.volume_api = volume.API() self.volume_api = volume.API()
self.compute_api = compute.API(network_api=self.network_api, self.compute_api = compute.API(network_api=self.network_api,
volume_api=self.volume_api) volume_api=self.volume_api)
self.setup()
def __str__(self): def __str__(self):
return 'CloudController' 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): def _get_image_state(self, image):
# NOTE(vish): fallback status if image_state isn't set # NOTE(vish): fallback status if image_state isn't set
state = image.get('status') state = image.get('status')

View File

@ -60,28 +60,11 @@ class CloudpipeController(object):
def setup(self): def setup(self):
"""Ensure the keychains and folders exist.""" """Ensure the keychains and folders exist."""
# TODO(todd): this was copyed from api.ec2.cloud # NOTE(vish): One of the drawbacks of doing this in the api is
# FIXME(ja): this should be moved to a nova-manage command, # the keys will only be on the api node that launched
# if not setup throw exceptions instead of running # the cloudpipe.
# Create keys folder, if it doesn't exist
if not os.path.exists(FLAGS.keys_path): if not os.path.exists(FLAGS.keys_path):
os.makedirs(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): def _get_cloudpipe_for_project(self, context, project_id):
"""Get the cloudpipe instance for a project ID.""" """Get the cloudpipe instance for a project ID."""

15
nova/cert/__init__.py Normal file
View File

@ -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.

67
nova/cert/manager.py Normal file
View File

@ -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))

View File

@ -39,6 +39,7 @@ gettext.install('nova', unicode=1)
from nova import context from nova import context
from nova import db from nova import db
from nova import exception
from nova import flags from nova import flags
from nova import log as logging 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) 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): def fetch_ca(project_id=None):
if not FLAGS.use_project_ca: if not FLAGS.use_project_ca:
project_id = None project_id = None
@ -92,6 +97,22 @@ def fetch_ca(project_id=None):
return cafile.read() 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): def _generate_fingerprint(public_key_file):
(out, err) = utils.execute('ssh-keygen', '-q', '-l', '-f', public_key_file) (out, err) = utils.execute('ssh-keygen', '-q', '-l', '-f', public_key_file)
fingerprint = out.split(' ')[1] 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) 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): def revoke_cert(project_id, file_name):
"""Revoke a cert by file name.""" """Revoke a cert by file name."""
start = os.getcwd() start = os.getcwd()

View File

@ -179,6 +179,10 @@ class NovaException(Exception):
super(NovaException, self).__init__(message) super(NovaException, self).__init__(message)
class DecryptionFailure(NovaException):
message = _("Failed to decrypt text")
class ImagePaginationFailed(NovaException): class ImagePaginationFailed(NovaException):
message = _("Failed to paginate through images from image service") message = _("Failed to paginate through images from image service")

View File

@ -275,6 +275,7 @@ DEFINE_integer('glance_num_retries', 0,
DEFINE_integer('s3_port', 3333, 's3 port') DEFINE_integer('s3_port', 3333, 's3 port')
DEFINE_string('s3_host', '$my_ip', 's3 host (for infrastructure)') DEFINE_string('s3_host', '$my_ip', 's3 host (for infrastructure)')
DEFINE_string('s3_dmz', '$my_ip', 's3 dmz ip (for instances)') 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('compute_topic', 'compute', 'the topic compute nodes listen on')
DEFINE_string('console_topic', 'console', DEFINE_string('console_topic', 'console',
'the topic console proxy nodes listen on') 'the topic console proxy nodes listen on')
@ -367,6 +368,8 @@ DEFINE_string('compute_manager', 'nova.compute.manager.ComputeManager',
'Manager for compute') 'Manager for compute')
DEFINE_string('console_manager', 'nova.console.manager.ConsoleProxyManager', DEFINE_string('console_manager', 'nova.console.manager.ConsoleProxyManager',
'Manager for console proxy') 'Manager for console proxy')
DEFINE_string('cert_manager', 'nova.cert.manager.CertManager',
'Manager for cert')
DEFINE_string('instance_dns_manager', DEFINE_string('instance_dns_manager',
'nova.network.dns_driver.DNSDriver', 'nova.network.dns_driver.DNSDriver',
'DNS Manager for instance IPs') 'DNS Manager for instance IPs')

View File

@ -18,6 +18,7 @@
"""Proxy AMI-related calls from cloud controller to objectstore service.""" """Proxy AMI-related calls from cloud controller to objectstore service."""
import base64
import binascii import binascii
import os import os
import shutil import shutil
@ -28,7 +29,7 @@ from xml.etree import ElementTree
import boto.s3.connection import boto.s3.connection
import eventlet import eventlet
from nova import crypto from nova import rpc
import nova.db.api import nova.db.api
from nova import exception from nova import exception
from nova import flags from nova import flags
@ -302,14 +303,9 @@ class S3ImageService(object):
hex_iv = manifest.find('image/ec2_encrypted_iv').text hex_iv = manifest.find('image/ec2_encrypted_iv').text
encrypted_iv = binascii.a2b_hex(hex_iv) 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') dec_filename = os.path.join(image_path, 'image.tar.gz')
self._decrypt_image(enc_filename, encrypted_key, self._decrypt_image(context, enc_filename, encrypted_key,
encrypted_iv, cloud_pk, encrypted_iv, dec_filename)
dec_filename)
except Exception: except Exception:
LOG.exception(_("Failed to decrypt %(image_location)s " LOG.exception(_("Failed to decrypt %(image_location)s "
"to %(image_path)s"), log_vars) "to %(image_path)s"), log_vars)
@ -353,39 +349,38 @@ class S3ImageService(object):
return image return image
@staticmethod @staticmethod
def _decrypt_image(encrypted_filename, encrypted_key, encrypted_iv, def _decrypt_image(context, encrypted_filename, encrypted_key,
cloud_private_key, decrypted_filename): encrypted_iv, decrypted_filename):
key, err = utils.execute('openssl', elevated = context.elevated()
'rsautl', try:
'-decrypt', key = rpc.call(elevated, FLAGS.cert_topic,
'-inkey', '%s' % cloud_private_key, {"method": "decrypt_text",
process_input=encrypted_key, "args": {"project_id": context.project_id,
check_exit_code=False) "text": base64.b64encode(encrypted_key)}})
if err: except Exception, exc:
raise exception.Error(_('Failed to decrypt private key: %s') raise exception.Error(_('Failed to decrypt private key: %s')
% err) % exc)
iv, err = utils.execute('openssl', try:
'rsautl', iv = rpc.call(elevated, FLAGS.cert_topic,
'-decrypt', {"method": "decrypt_text",
'-inkey', '%s' % cloud_private_key, "args": {"project_id": context.project_id,
process_input=encrypted_iv, "text": base64.b64encode(encrypted_iv)}})
check_exit_code=False) except Exception, exc:
if err:
raise exception.Error(_('Failed to decrypt initialization ' raise exception.Error(_('Failed to decrypt initialization '
'vector: %s') % err) 'vector: %s') % exc)
_out, err = utils.execute('openssl', 'enc', try:
'-d', '-aes-128-cbc', utils.execute('openssl', 'enc',
'-in', '%s' % (encrypted_filename,), '-d', '-aes-128-cbc',
'-K', '%s' % (key,), '-in', '%s' % (encrypted_filename,),
'-iv', '%s' % (iv,), '-K', '%s' % (key,),
'-out', '%s' % (decrypted_filename,), '-iv', '%s' % (iv,),
check_exit_code=False) '-out', '%s' % (decrypted_filename,))
if err: except exception.ProcessExecutionError, exc:
raise exception.Error(_('Failed to decrypt image file ' raise exception.Error(_('Failed to decrypt image file '
'%(image_file)s: %(err)s') % '%(image_file)s: %(err)s') %
{'image_file': encrypted_filename, {'image_file': encrypted_filename,
'err': err}) 'err': exc.stdout})
@staticmethod @staticmethod
def _test_for_malicious_tarball(path, filename): def _test_for_malicious_tarball(path, filename):

View File

@ -16,7 +16,6 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from M2Crypto import X509
import unittest import unittest
from nova import crypto from nova import crypto
@ -245,28 +244,6 @@ class _AuthManagerBaseTestCase(test.TestCase):
project)) project))
self.assertFalse(self.manager.is_project_member(user, 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): def test_adding_role_to_project_is_ignored_unless_added_to_user(self):
with user_and_project_generator(self.manager) as (user, project): with user_and_project_generator(self.manager) as (user, project):
self.assertFalse(self.manager.has_role(user, 'sysadmin', project)) self.assertFalse(self.manager.has_role(user, 'sysadmin', project))

View File

@ -16,12 +16,20 @@
Tests for Crypto module. Tests for Crypto module.
""" """
import os
import shutil
import tempfile
import mox import mox
import stubout from M2Crypto import X509
from nova import crypto from nova import crypto
from nova import db from nova import db
from nova import flags
from nova import test from nova import test
from nova import utils
FLAGS = flags.FLAGS
class SymmetricKeyTestCase(test.TestCase): class SymmetricKeyTestCase(test.TestCase):
@ -52,16 +60,55 @@ class SymmetricKeyTestCase(test.TestCase):
self.assertEquals(plain_text, plain) 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): 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): def test_revoke_certs_by_user_and_project(self):
user_id = 'test_user' user_id = 'test_user'
project_id = 2 project_id = 2

View File

@ -89,6 +89,7 @@ setup(name='nova',
'bin/nova-api-metadata', 'bin/nova-api-metadata',
'bin/nova-api-os-compute', 'bin/nova-api-os-compute',
'bin/nova-api-os-volume', 'bin/nova-api-os-volume',
'bin/nova-cert',
'bin/nova-compute', 'bin/nova-compute',
'bin/nova-console', 'bin/nova-console',
'bin/nova-consoleauth', 'bin/nova-consoleauth',