diff --git a/nova/image/glance.py b/nova/image/glance.py index afa5ea321bad..87a8b4cf5631 100644 --- a/nova/image/glance.py +++ b/nova/image/glance.py @@ -23,6 +23,7 @@ import random import sys import time +import cryptography import glanceclient from glanceclient.common import http import glanceclient.exc @@ -40,6 +41,8 @@ import six.moves.urllib.parse as urlparse from nova import exception from nova.i18n import _LE, _LI, _LW import nova.image.download as image_xfers +from nova import objects +from nova import signature_utils glance_opts = [ @@ -81,6 +84,10 @@ should be fully qualified urls of the form help='A list of url scheme that can be downloaded directly ' 'via the direct_url. Currently supported schemes: ' '[file].'), + cfg.BoolOpt('verify_glance_signatures', + default=False, + help='Require Nova to perform signature verification on ' + 'each image downloaded from Glance.'), ] LOG = logging.getLogger(__name__) @@ -366,17 +373,70 @@ class GlanceImageService(object): except Exception: _reraise_translated_image_exception(image_id) + # Retrieve properties for verification of Glance image signature + verifier = None + if CONF.glance.verify_glance_signatures: + image_meta_dict = self.show(context, image_id, + include_locations=False) + image_meta = objects.ImageMeta.from_dict(image_meta_dict) + img_signature = image_meta.properties.get('img_signature') + img_sig_hash_method = image_meta.properties.get( + 'img_signature_hash_method' + ) + img_sig_cert_uuid = image_meta.properties.get( + 'img_signature_certificate_uuid' + ) + img_sig_key_type = image_meta.properties.get( + 'img_signature_key_type' + ) + try: + verifier = signature_utils.get_verifier(context, + img_sig_cert_uuid, + img_sig_hash_method, + img_signature, + img_sig_key_type) + except exception.SignatureVerificationError: + with excutils.save_and_reraise_exception(): + LOG.error(_LE('Image signature verification failed ' + 'for image: %s'), image_id) + close_file = False if data is None and dst_path: data = open(dst_path, 'wb') close_file = True if data is None: + + # Perform image signature verification + if verifier: + try: + for chunk in image_chunks: + verifier.update(chunk) + verifier.verify() + + LOG.info(_LI('Image signature verification succeeded ' + 'for image: %s'), image_id) + + except cryptography.exceptions.InvalidSignature: + with excutils.save_and_reraise_exception(): + LOG.error(_LE('Image signature verification failed ' + 'for image: %s'), image_id) return image_chunks else: try: for chunk in image_chunks: + if verifier: + verifier.update(chunk) data.write(chunk) + if verifier: + verifier.verify() + LOG.info(_LI('Image signature verification succeeded ' + 'for image %s'), image_id) + except cryptography.exceptions.InvalidSignature: + data.truncate(0) + with excutils.save_and_reraise_exception(): + LOG.error(_LE('Image signature verification failed ' + 'for image: %s'), image_id) except Exception as ex: with excutils.save_and_reraise_exception(): LOG.error(_LE("Error writing to %(path)s: %(exception)s"), diff --git a/nova/objects/fields.py b/nova/objects/fields.py index 7cbb1f92e8da..4bdf5e3279e4 100644 --- a/nova/objects/fields.py +++ b/nova/objects/fields.py @@ -251,6 +251,27 @@ class HVType(Enum): return super(HVType, self).coerce(obj, attr, value) +class ImageSignatureHashType(Enum): + # Represents the possible hash methods used for image signing + def __init__(self): + self.hashes = ('SHA-224', 'SHA-256', 'SHA-384', 'SHA-512') + super(ImageSignatureHashType, self).__init__( + valid_values=self.hashes + ) + + +class ImageSignatureKeyType(Enum): + # Represents the possible keypair types used for image signing + def __init__(self): + self.key_types = ( + 'DSA', 'ECC_SECT571K1', 'ECC_SECT409K1', 'ECC_SECT571R1', + 'ECC_SECT409R1', 'ECC_SECP521R1', 'ECC_SECP384R1', 'RSA-PSS' + ) + super(ImageSignatureKeyType, self).__init__( + valid_values=self.key_types + ) + + class OSType(Enum): LINUX = "linux" @@ -734,6 +755,14 @@ class HVTypeField(BaseEnumField): AUTO_TYPE = HVType() +class ImageSignatureHashTypeField(BaseEnumField): + AUTO_TYPE = ImageSignatureHashType() + + +class ImageSignatureKeyTypeField(BaseEnumField): + AUTO_TYPE = ImageSignatureKeyType() + + class OSTypeField(BaseEnumField): AUTO_TYPE = OSType() diff --git a/nova/objects/image_meta.py b/nova/objects/image_meta.py index c0ce1251c898..72a16dc45984 100644 --- a/nova/objects/image_meta.py +++ b/nova/objects/image_meta.py @@ -160,7 +160,8 @@ class ImageMetaProps(base.NovaObject): # Version 1.9: added hw_cpu_thread_policy field # Version 1.10: added hw_cpu_realtime_mask field # Version 1.11: Added hw_firmware_type field - VERSION = '1.11' + # Version 1.12: Added properties for image signature verification + VERSION = '1.12' def obj_make_compatible(self, primitive, target_version): super(ImageMetaProps, self).obj_make_compatible(primitive, @@ -371,6 +372,19 @@ class ImageMetaProps(base.NovaObject): # integer value 1 'img_version': fields.IntegerField(), + # base64 of encoding of image signature + 'img_signature': fields.StringField(), + + # string indicating hash method used to compute image signature + 'img_signature_hash_method': fields.ImageSignatureHashTypeField(), + + # string indicating Castellan uuid of certificate + # used to compute the image's signature + 'img_signature_certificate_uuid': fields.UUIDField(), + + # string indicating type of key used to compute image signature + 'img_signature_key_type': fields.ImageSignatureKeyTypeField(), + # string of username with admin privileges 'os_admin_user': fields.StringField(), diff --git a/nova/tests/unit/image/test_glance.py b/nova/tests/unit/image/test_glance.py index 10f4ae31aec4..09aee7d552d7 100644 --- a/nova/tests/unit/image/test_glance.py +++ b/nova/tests/unit/image/test_glance.py @@ -17,6 +17,7 @@ import datetime from six.moves import StringIO +import cryptography import glanceclient.exc import mock from oslo_config import cfg @@ -673,6 +674,147 @@ class TestDownloadNoDirectUri(test.NoDBTestCase): writer.close.assert_called_once_with() +class TestDownloadSignatureVerification(test.NoDBTestCase): + + class MockVerifier(object): + def update(self, data): + return + + def verify(self): + return True + + class BadVerifier(object): + def update(self, data): + return + + def verify(self): + raise cryptography.exceptions.InvalidSignature( + 'Invalid signature.' + ) + + def setUp(self): + super(TestDownloadSignatureVerification, self).setUp() + self.flags(verify_glance_signatures=True, group='glance') + self.fake_img_props = { + 'properties': { + 'img_signature': 'signature', + 'img_signature_hash_method': 'SHA-224', + 'img_signature_certificate_uuid': 'uuid', + 'img_signature_key_type': 'RSA-PSS', + } + } + self.fake_img_data = ['A' * 256, 'B' * 256] + client = mock.MagicMock() + client.call.return_value = self.fake_img_data + self.service = glance.GlanceImageService(client) + + @mock.patch('nova.image.glance.LOG') + @mock.patch('nova.image.glance.GlanceImageService.show') + @mock.patch('nova.signature_utils.get_verifier') + def test_download_with_signature_verification(self, + mock_get_verifier, + mock_show, + mock_log): + mock_get_verifier.return_value = self.MockVerifier() + mock_show.return_value = self.fake_img_props + res = self.service.download(context=None, image_id=None, + data=None, dst_path=None) + self.assertEqual(self.fake_img_data, res) + mock_get_verifier.assert_called_once_with(None, 'uuid', 'SHA-224', + 'signature', 'RSA-PSS') + mock_log.info.assert_called_once_with(mock.ANY, mock.ANY) + + @mock.patch.object(six.moves.builtins, 'open') + @mock.patch('nova.image.glance.LOG') + @mock.patch('nova.image.glance.GlanceImageService.show') + @mock.patch('nova.signature_utils.get_verifier') + def test_download_dst_path_signature_verification(self, + mock_get_verifier, + mock_show, + mock_log, + mock_open): + mock_get_verifier.return_value = self.MockVerifier() + mock_show.return_value = self.fake_img_props + mock_dest = mock.MagicMock() + fake_path = 'FAKE_PATH' + mock_open.return_value = mock_dest + self.service.download(context=None, image_id=None, + data=None, dst_path=fake_path) + mock_get_verifier.assert_called_once_with(None, 'uuid', 'SHA-224', + 'signature', 'RSA-PSS') + mock_log.info.assert_called_once_with(mock.ANY, mock.ANY) + self.assertEqual(len(self.fake_img_data), mock_dest.write.call_count) + self.assertTrue(mock_dest.close.called) + + @mock.patch('nova.image.glance.LOG') + @mock.patch('nova.image.glance.GlanceImageService.show') + @mock.patch('nova.signature_utils.get_verifier') + def test_download_with_get_verifier_failure(self, + mock_get_verifier, + mock_show, + mock_log): + mock_get_verifier.side_effect = exception.SignatureVerificationError( + reason='Signature verification ' + 'failed.' + ) + mock_show.return_value = self.fake_img_props + self.assertRaises(exception.SignatureVerificationError, + self.service.download, + context=None, image_id=None, + data=None, dst_path=None) + mock_log.error.assert_called_once_with(mock.ANY, mock.ANY) + + @mock.patch('nova.image.glance.LOG') + @mock.patch('nova.image.glance.GlanceImageService.show') + @mock.patch('nova.signature_utils.get_verifier') + def test_download_with_invalid_signature(self, + mock_get_verifier, + mock_show, + mock_log): + mock_get_verifier.return_value = self.BadVerifier() + mock_show.return_value = self.fake_img_props + self.assertRaises(cryptography.exceptions.InvalidSignature, + self.service.download, + context=None, image_id=None, + data=None, dst_path=None) + mock_log.error.assert_called_once_with(mock.ANY, mock.ANY) + + @mock.patch('nova.image.glance.LOG') + @mock.patch('nova.image.glance.GlanceImageService.show') + def test_download_missing_signature_metadata(self, + mock_show, + mock_log): + mock_show.return_value = {'properties': {}} + self.assertRaisesRegex(exception.SignatureVerificationError, + 'Required image properties for signature ' + 'verification do not exist. Cannot verify ' + 'signature. Missing property: .*', + self.service.download, + context=None, image_id=None, + data=None, dst_path=None) + + @mock.patch.object(six.moves.builtins, 'open') + @mock.patch('nova.signature_utils.get_verifier') + @mock.patch('nova.image.glance.LOG') + @mock.patch('nova.image.glance.GlanceImageService.show') + def test_download_dst_path_signature_fail(self, mock_show, + mock_log, mock_get_verifier, + mock_open): + mock_get_verifier.return_value = self.BadVerifier() + mock_dest = mock.MagicMock() + fake_path = 'FAKE_PATH' + mock_open.return_value = mock_dest + mock_show.return_value = self.fake_img_props + self.assertRaises(cryptography.exceptions.InvalidSignature, + self.service.download, + context=None, image_id=None, + data=None, dst_path=fake_path) + mock_log.error.assert_called_once_with(mock.ANY, mock.ANY) + mock_open.assert_called_once_with(fake_path, 'wb') + mock_dest.truncate.assert_called_once_with(0) + self.assertTrue(mock_dest.close.called) + + class TestIsImageAvailable(test.NoDBTestCase): """Tests the internal _is_image_available function.""" diff --git a/nova/tests/unit/objects/test_fields.py b/nova/tests/unit/objects/test_fields.py index 3af8a7b10d1a..5ade8e53fae3 100644 --- a/nova/tests/unit/objects/test_fields.py +++ b/nova/tests/unit/objects/test_fields.py @@ -21,6 +21,7 @@ import six from nova.network import model as network_model from nova.objects import fields +from nova import signature_utils from nova import test from nova import utils @@ -422,6 +423,25 @@ class TestHVType(TestField): self.assertRaises(ValueError, self.field.stringify, 'acme') +class TestImageSignatureTypes(TestField): + # Ensure that the object definition is updated + # in step with the signature_utils module + def setUp(self): + super(TestImageSignatureTypes, self).setUp() + self.hash_field = fields.ImageSignatureHashType() + self.key_type_field = fields.ImageSignatureKeyType() + + def test_hashes(self): + for hash_name in list(signature_utils.HASH_METHODS.keys()): + self.assertIn(hash_name, self.hash_field.hashes) + + def test_key_types(self): + key_type_dict = signature_utils.SignatureKeyType._REGISTERED_TYPES + key_types = list(key_type_dict.keys()) + for key_type in key_types: + self.assertIn(key_type, self.key_type_field.key_types) + + class TestOSType(TestField): def setUp(self): super(TestOSType, self).setUp() diff --git a/nova/tests/unit/objects/test_objects.py b/nova/tests/unit/objects/test_objects.py index e4afdad43e5b..05b4a98b6896 100644 --- a/nova/tests/unit/objects/test_objects.py +++ b/nova/tests/unit/objects/test_objects.py @@ -1136,7 +1136,7 @@ object_data = { 'HostMapping': '1.0-1a3390a696792a552ab7bd31a77ba9ac', 'HVSpec': '1.2-db672e73304da86139086d003f3977e7', 'ImageMeta': '1.8-642d1b2eb3e880a367f37d72dd76162d', - 'ImageMetaProps': '1.11-96aa14a8ba226701bbd22e63557a63ea', + 'ImageMetaProps': '1.12-6a132dee47931447bf86c03c7006d96c', 'Instance': '2.1-416fdd0dfc33dfa12ff2cfdd8cc32e17', 'InstanceAction': '1.1-f9f293e526b66fca0d05c3b3a2d13914', 'InstanceActionEvent': '1.1-e56a64fa4710e43ef7af2ad9d6028b33',