Merge "Add image signature verification"
This commit is contained in:
commit
18a45ae36f
@ -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"),
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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(),
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user