diff --git a/glance/api/v2/image_data.py b/glance/api/v2/image_data.py index ee7d422f48..317c7aa592 100644 --- a/glance/api/v2/image_data.py +++ b/glance/api/v2/image_data.py @@ -63,6 +63,22 @@ def _restore(self, image_repo, image): 'e': encodeutils.exception_to_unicode(e)}) LOG.exception(msg) + def _delete(self, image_repo, image): + """Delete the image. + + :param image_repo: The instance of ImageRepo + :param image: The image that will be deleted + """ + try: + if image_repo and image: + image.status = 'killed' + image_repo.save(image) + except Exception as e: + msg = (_LE("Unable to delete image %(image_id)s: %(e)s") % + {'image_id': image.image_id, + 'e': encodeutils.exception_to_unicode(e)}) + LOG.exception(msg) + @utils.mutating def upload(self, req, image_id, data, size): image_repo = self.gateway.get_repo(req.context) @@ -152,6 +168,14 @@ def upload(self, req, image_id, data, size): raise webob.exc.HTTPServiceUnavailable(explanation=msg, request=req) + except exception.SignatureVerificationError as e: + msg = (_LE("Signature verification failed for image %(id)s: %(e)s") + % {'id': image_id, + 'e': encodeutils.exception_to_unicode(e)}) + LOG.error(msg) + self._delete(image_repo, image) + raise webob.exc.HTTPBadRequest(explanation=msg) + except webob.exc.HTTPGone as e: with excutils.save_and_reraise_exception(): LOG.error(_LE("Failed to upload image data due to HTTP error")) diff --git a/glance/common/exception.py b/glance/common/exception.py index b7796fae08..b68c335797 100644 --- a/glance/common/exception.py +++ b/glance/common/exception.py @@ -447,6 +447,10 @@ class MetadefTagNotFound(NotFound): " namespace=%(namespace_name)s.") +class SignatureVerificationError(GlanceException): + message = _("Unable to verify signature: %(reason)s") + + class InvalidVersion(Invalid): message = _("Version is invalid: %(reason)s") diff --git a/glance/common/signature_utils.py b/glance/common/signature_utils.py new file mode 100644 index 0000000000..b925d433cf --- /dev/null +++ b/glance/common/signature_utils.py @@ -0,0 +1,281 @@ +# Copyright (c) The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# 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. + +"""Support signature verification.""" + +import base64 + +from castellan import key_manager +from cryptography import exceptions as crypto_exception +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes +from cryptography import x509 +from oslo_log import log as logging +from oslo_utils import encodeutils + +from glance.common import exception +from glance import i18n + +LOG = logging.getLogger(__name__) +_ = i18n._ +_LE = i18n._LE + + +# Note: This is the signature hash method, which is independent from the +# image data checksum hash method (which is handled elsewhere). +HASH_METHODS = { + 'SHA-224': hashes.SHA224(), + 'SHA-256': hashes.SHA256(), + 'SHA-384': hashes.SHA384(), + 'SHA-512': hashes.SHA512() +} + +# These are the currently supported signature formats +(RSA_PSS,) = ( + 'RSA-PSS', +) + +SIGNATURE_KEY_TYPES = { + RSA_PSS +} + +# These are the currently supported certificate formats +(X_509,) = ( + 'X.509', +) + +CERTIFICATE_FORMATS = { + X_509 +} + +# These are the currently supported MGF formats, used for RSA-PSS signatures +MASK_GEN_ALGORITHMS = { + 'MGF1': padding.MGF1 +} + +# Required image property names +(SIGNATURE, HASH_METHOD, KEY_TYPE, CERT_UUID) = ( + 'signature', + 'signature_hash_method', + 'signature_key_type', + 'signature_certificate_uuid' +) + +# Optional image property names for RSA-PSS +(MASK_GEN_ALG, PSS_SALT_LENGTH) = ( + 'mask_gen_algorithm', + 'pss_salt_length' +) + + +def should_verify_signature(image_properties): + """Determine whether a signature should be verified. + + Using the image properties, determine whether existing properties indicate + that signature verification should be done. + + :param image_properties: the key-value properties about the image + :return: True, if signature metadata properties exist, False otherwise + """ + return (image_properties is not None and + CERT_UUID in image_properties and + HASH_METHOD in image_properties and + SIGNATURE in image_properties and + KEY_TYPE in image_properties) + + +def verify_signature(context, checksum_hash, image_properties): + """Retrieve the image properties and use them to verify the signature. + + :param context: the user context for authentication + :param checksum_hash: the 'checksum' hash of the image data + :param image_properties: the key-value properties about the image + :return: True if verification succeeds + :raises: SignatureVerificationError if verification fails + """ + if not should_verify_signature(image_properties): + raise exception.SignatureVerificationError( + 'Required image properties for signature verification do not' + ' exist. Cannot verify signature.') + + signature = get_signature(image_properties[SIGNATURE]) + hash_method = get_hash_method(image_properties[HASH_METHOD]) + signature_key_type = get_signature_key_type( + image_properties[KEY_TYPE]) + public_key = get_public_key(context, + image_properties[CERT_UUID], + signature_key_type) + + # Initialize the verifier + verifier = None + + # create the verifier based on the signature key type + if signature_key_type == RSA_PSS: + # retrieve other needed properties, or use defaults if not there + if MASK_GEN_ALG in image_properties: + mask_gen_algorithm = image_properties[MASK_GEN_ALG] + if mask_gen_algorithm in MASK_GEN_ALGORITHMS: + mgf = MASK_GEN_ALGORITHMS[mask_gen_algorithm](hash_method) + else: + raise exception.SignatureVerificationError( + 'Invalid mask_gen_algorithm: %s' % mask_gen_algorithm) + else: + # default to MGF1 + mgf = padding.MGF1(hash_method) + if PSS_SALT_LENGTH in image_properties: + pss_salt_length = image_properties[PSS_SALT_LENGTH] + try: + salt_length = int(pss_salt_length) + except ValueError: + raise exception.SignatureVerificationError( + 'Invalid pss_salt_length: %s' % pss_salt_length) + else: + # default to max salt length + salt_length = padding.PSS.MAX_LENGTH + # Create the verifier + verifier = public_key.verifier( + signature, + padding.PSS( + mgf=mgf, + salt_length=salt_length + ), + hash_method + ) + + if verifier: + # Verify the signature + verifier.update(checksum_hash) + try: + verifier.verify() + return True + except crypto_exception.InvalidSignature: + raise exception.SignatureVerificationError( + 'Signature verification failed.') + else: + # Error creating the verifier + raise exception.SignatureVerificationError( + 'Error occurred while verifying the signature') + + +def get_signature(signature_data): + """Decode the signature data and returns the signature. + + :param siganture_data: the base64-encoded signature data + :return: the decoded signature + :raises: SignatureVerificationError if the signature data is malformatted + """ + try: + signature = base64.b64decode(signature_data) + except TypeError: + raise exception.SignatureVerificationError( + 'The signature data was not properly encoded using base64') + + return signature + + +def get_hash_method(hash_method_name): + """Verify the hash method name and create the hash method. + + :param hash_method_name: the name of the hash method to retrieve + :return: the hash method, a cryptography object + :raises: SignatureVerificationError if the hash method name is invalid + """ + if hash_method_name not in HASH_METHODS: + raise exception.SignatureVerificationError( + 'Invalid signature hash method: %s' % hash_method_name) + + return HASH_METHODS[hash_method_name] + + +def get_signature_key_type(signature_key_type): + """Verify the signature key type. + + :param signature_key_type: the key type of the signature + :return: the validated signature key type + :raises: SignatureVerificationError if the signature key type is invalid + """ + if signature_key_type not in SIGNATURE_KEY_TYPES: + raise exception.SignatureVerificationError( + 'Invalid signature key type: %s' % signature_key_type) + + return signature_key_type + + +def get_public_key(context, signature_certificate_uuid, signature_key_type): + """Create the public key object from a retrieved certificate. + + :param context: the user context for authentication + :param signature_certificate_uuid: the uuid to use to retrieve the + certificate + :param signature_key_type: the key type of the signature + :return: the public key cryptography object + :raises: SignatureVerificationError if public key format is invalid + """ + certificate = get_certificate(context, signature_certificate_uuid) + + # Note that this public key could either be + # RSAPublicKey, DSAPublicKey, or EllipticCurvePublicKey + public_key = certificate.public_key() + + # Confirm the type is of the type expected based on the signature key type + if signature_key_type == RSA_PSS: + if not isinstance(public_key, rsa.RSAPublicKey): + raise exception.SignatureVerificationError( + 'Invalid public key type for signature key type: %s' + % signature_key_type) + + return public_key + + +def get_certificate(context, signature_certificate_uuid): + """Create the certificate object from the retrieved certificate data. + + :param context: the user context for authentication + :param signature_certificate_uuid: the uuid to use to retrieve the + certificate + :return: the certificate cryptography object + :raises: SignatureVerificationError if the retrieval fails or the format + is invalid + """ + keymgr_api = key_manager.API() + + try: + # The certificate retrieved here is a castellan certificate object + cert = keymgr_api.get(context, signature_certificate_uuid) + except Exception as e: + # The problem encountered may be backend-specific, since castellan + # can use different backends. Rather than importing all possible + # backends here, the generic "Exception" is used. + msg = (_LE("Unable to retrieve certificate with ID %(id)s: %(e)s") + % {'id': signature_certificate_uuid, + 'e': encodeutils.exception_to_unicode(e)}) + LOG.error(msg) + raise exception.SignatureVerificationError( + 'Unable to retrieve certificate with ID: %s' + % signature_certificate_uuid) + + if cert.format not in CERTIFICATE_FORMATS: + raise exception.SignatureVerificationError( + 'Invalid certificate format: %s' % cert.format) + + if cert.format == X_509: + # castellan always encodes certificates in DER format + cert_data = cert.get_encoded() + certificate = x509.load_der_x509_certificate(cert_data, + default_backend()) + + return certificate diff --git a/glance/location.py b/glance/location.py index 84ceda0ec6..20f8f76bdd 100644 --- a/glance/location.py +++ b/glance/location.py @@ -23,6 +23,7 @@ from oslo_utils import excutils from glance.common import exception +from glance.common import signature_utils from glance.common import utils import glance.domain.proxy from glance import i18n @@ -30,6 +31,7 @@ _ = i18n._ _LE = i18n._LE +_LI = i18n._LI CONF = cfg.CONF LOG = logging.getLogger(__name__) @@ -375,6 +377,18 @@ def set_data(self, data, size=None): CONF.image_size_cap), size, context=self.context) + + # Verify the signature (if correct properties are present) + if (signature_utils.should_verify_signature( + self.image.extra_properties)): + # NOTE(bpoulos): if verification fails, exception will be raised + result = signature_utils.verify_signature( + self.context, checksum, self.image.extra_properties) + if result: + msg = (_LI("Successfully verified signature for image " + "%s") % self.image.image_id) + LOG.info(msg) + self.image.locations = [{'url': location, 'metadata': loc_meta, 'status': 'active'}] self.image.size = size diff --git a/glance/tests/unit/test_store_image.py b/glance/tests/unit/test_store_image.py index b7703ac31a..43b4a38a88 100644 --- a/glance/tests/unit/test_store_image.py +++ b/glance/tests/unit/test_store_image.py @@ -16,6 +16,7 @@ import mock from glance.common import exception +from glance.common import signature_utils import glance.location from glance.tests.unit import base as unit_test_base from glance.tests.unit import utils as unit_test_utils @@ -41,12 +42,13 @@ def save(self, image, from_state=None): class ImageStub(object): def __init__(self, image_id, status=None, locations=None, - visibility=None): + visibility=None, extra_properties=None): self.image_id = image_id self.status = status self.locations = locations or [] self.visibility = visibility self.size = 1 + self.extra_properties = extra_properties or {} def delete(self): self.status = 'deleted' @@ -60,7 +62,8 @@ def new_image(self, image_id=None, name=None, visibility='private', min_disk=0, min_ram=0, protected=False, owner=None, disk_format=None, container_format=None, extra_properties=None, tags=None, **other_args): - return ImageStub(image_id, visibility=visibility, **other_args) + return ImageStub(image_id, visibility=visibility, + extra_properties=extra_properties, **other_args) class FakeMemberRepo(object): @@ -185,6 +188,62 @@ def test_image_set_data_unknown_size(self): self.store_api.get_from_backend, image.locations[0]['url'], context={}) + def test_image_set_data_valid_signature(self): + context = glance.context.RequestContext(user=USER1) + extra_properties = { + 'signature_certificate_uuid': 'UUID', + 'signature_hash_method': 'METHOD', + 'signature_key_type': 'TYPE', + 'signature': 'VALID' + } + image_stub = ImageStub(UUID2, status='queued', + extra_properties=extra_properties) + self.stubs.Set(signature_utils, 'verify_signature', + unit_test_utils.fake_verify_signature) + image = glance.location.ImageProxy(image_stub, context, + self.store_api, self.store_utils) + image.set_data('YYYY', 4) + self.assertEqual(UUID2, image.locations[0]['url']) + self.assertEqual('Z', image.checksum) + self.assertEqual('active', image.status) + + def test_image_set_data_invalid_signature(self): + context = glance.context.RequestContext(user=USER1) + extra_properties = { + 'signature_certificate_uuid': 'UUID', + 'signature_hash_method': 'METHOD', + 'signature_key_type': 'TYPE', + 'signature': 'INVALID' + } + image_stub = ImageStub(UUID2, status='queued', + extra_properties=extra_properties) + self.stubs.Set(signature_utils, 'verify_signature', + unit_test_utils.fake_verify_signature) + image = glance.location.ImageProxy(image_stub, context, + self.store_api, self.store_utils) + self.assertRaises(exception.SignatureVerificationError, + image.set_data, + 'YYYY', 4) + + def test_image_set_data_invalid_signature_missing_metadata(self): + context = glance.context.RequestContext(user=USER1) + extra_properties = { + 'signature_hash_method': 'METHOD', + 'signature_key_type': 'TYPE', + 'signature': 'INVALID' + } + image_stub = ImageStub(UUID2, status='queued', + extra_properties=extra_properties) + self.stubs.Set(signature_utils, 'verify_signature', + unit_test_utils.fake_verify_signature) + image = glance.location.ImageProxy(image_stub, context, + self.store_api, self.store_utils) + image.set_data('YYYY', 4) + self.assertEqual(UUID2, image.locations[0]['url']) + self.assertEqual('Z', image.checksum) + # Image is still active, since invalid signature was ignored + self.assertEqual('active', image.status) + def _add_image(self, context, image_id, data, len): image_stub = ImageStub(image_id, status='queued', locations=[]) image = glance.location.ImageProxy(image_stub, context, diff --git a/glance/tests/unit/utils.py b/glance/tests/unit/utils.py index a1863cb070..24c2d22272 100644 --- a/glance/tests/unit/utils.py +++ b/glance/tests/unit/utils.py @@ -83,6 +83,15 @@ def fake_get_size_from_backend(uri, context=None): return 1 +def fake_verify_signature(context, checksum_hash, image_properties): + if (image_properties is not None and 'signature' in image_properties and + image_properties['signature'] == 'VALID'): + return True + else: + raise exception.SignatureVerificationError( + 'Signature verification failed.') + + class FakeDB(object): def __init__(self, initialize=True): diff --git a/glance/tests/unit/v2/test_image_data_resource.py b/glance/tests/unit/v2/test_image_data_resource.py index b43ca20be3..2e5da38f0d 100644 --- a/glance/tests/unit/v2/test_image_data_resource.py +++ b/glance/tests/unit/v2/test_image_data_resource.py @@ -249,6 +249,15 @@ def test_upload_storage_full(self): self.controller.upload, request, unit_test_utils.UUID2, 'YYYYYYY', 7) + def test_upload_signature_verification_fails(self): + request = unit_test_utils.get_fake_request() + image = FakeImage() + image.set_data = Raise(exception.SignatureVerificationError) + self.image_repo.result = image + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.upload, + request, unit_test_utils.UUID1, 'YYYY', 4) + self.assertEqual('killed', self.image_repo.saved_image.status) + def test_image_size_limit_exceeded(self): request = unit_test_utils.get_fake_request() image = FakeImage() diff --git a/requirements.txt b/requirements.txt index 1bf58820af..0c001205cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -67,3 +67,6 @@ glance-store>=0.7.1 # Apache-2.0 # Artifact repository semantic-version>=2.3.1 + +castellan>=0.2.0 # Apache-2.0 +cryptography>=1.0 # Apache-2.0