diff --git a/Authors b/Authors index cf5d08157a..8b29e6ccbc 100644 --- a/Authors +++ b/Authors @@ -26,6 +26,7 @@ Monty Taylor Rick Clark Rick Harris Soren Hansen +Stuart McLaren Taku Fukushima Thierry Carrez Tom Hancock diff --git a/etc/glance-api.conf b/etc/glance-api.conf index 2ee902ab2b..101accb212 100644 --- a/etc/glance-api.conf +++ b/etc/glance-api.conf @@ -34,6 +34,13 @@ backlog = 4096 # Private key file to use when starting API server securely # key_file = /path/to/keyfile +# ================= Security Options ========================== + +# AES key for encrypting store 'location' metadata, including +# -- if used -- Swift or S3 credentials +# Should be set to a random string of length 16, 24 or 32 bytes +# metadata_encryption_key = <16, 24 or 32 char registry metadata key> + # ============ Registry Options =============================== # Address to find the registry server diff --git a/glance/common/crypt.py b/glance/common/crypt.py new file mode 100644 index 0000000000..cddd13b5b9 --- /dev/null +++ b/glance/common/crypt.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# 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. + +""" +Routines for URL-safe encrypting/decrypting +""" + +import base64 +import string +import os + +from Crypto.Cipher import AES +from Crypto import Random +from Crypto.Random import random + + +def urlsafe_encrypt(key, plaintext, blocksize=16): + """ + Encrypts plaintext. Resulting ciphertext will contain URL-safe characters + :param key: AES secret key + :param plaintext: Input text to be encrypted + :param blocksize: Non-zero integer multiple of AES blocksize in bytes (16) + + :returns : Resulting ciphertext + """ + def pad(text): + """ + Pads text to be encrypted + """ + pad_length = (blocksize - len(text) % blocksize) + sr = random.StrongRandom() + pad = ''.join(chr(sr.randint(1, 0xFF)) for i in range(pad_length - 1)) + # We use chr(0) as a delimiter between text and padding + return text + chr(0) + pad + + # random initial 16 bytes for CBC + init_vector = Random.get_random_bytes(16) + cypher = AES.new(key, AES.MODE_CBC, init_vector) + padded = cypher.encrypt(pad(str(plaintext))) + return base64.urlsafe_b64encode(init_vector + padded) + + +def urlsafe_decrypt(key, ciphertext): + """ + Decrypts URL-safe base64 encoded ciphertext + :param key: AES secret key + :param ciphertext: The encrypted text to decrypt + + :returns : Resulting plaintext + """ + # Cast from unicode + ciphertext = base64.urlsafe_b64decode(str(ciphertext)) + cypher = AES.new(key, AES.MODE_CBC, ciphertext[:16]) + padded = cypher.decrypt(ciphertext[16:]) + return padded[:padded.rfind(chr(0))] diff --git a/glance/registry/__init__.py b/glance/registry/__init__.py index f2f1a23fbf..1db37d3383 100644 --- a/glance/registry/__init__.py +++ b/glance/registry/__init__.py @@ -30,6 +30,8 @@ logger = logging.getLogger('glance.registry') _CLIENT_HOST = None _CLIENT_PORT = None _CLIENT_KWARGS = {} +# AES key used to encrypt 'location' metadata +_METADATA_ENCRYPTION_KEY = None def configure_registry_client(options): @@ -38,7 +40,7 @@ def configure_registry_client(options): :param options: Configuration options coming from controller """ - global _CLIENT_KWARGS, _CLIENT_HOST, _CLIENT_PORT + global _CLIENT_KWARGS, _CLIENT_HOST, _CLIENT_PORT, _METADATA_ENCRYPTION_KEY try: host = options['registry_host'] port = int(options['registry_port']) @@ -56,7 +58,7 @@ def configure_registry_client(options): key_file = options.get('registry_client_key_file') cert_file = options.get('registry_client_cert_file') ca_file = options.get('registry_client_ca_file') - + _METADATA_ENCRYPTION_KEY = options.get('metadata_encryption_key') _CLIENT_HOST = host _CLIENT_PORT = port _CLIENT_KWARGS = {'use_ssl': use_ssl, @@ -66,10 +68,11 @@ def configure_registry_client(options): def get_registry_client(cxt): - global _CLIENT_KWARGS, _CLIENT_HOST, _CLIENT_PORT + global _CLIENT_KWARGS, _CLIENT_HOST, _CLIENT_PORT, _METADATA_ENCRYPTION_KEY kwargs = _CLIENT_KWARGS.copy() kwargs['auth_tok'] = cxt.auth_tok - return client.RegistryClient(_CLIENT_HOST, _CLIENT_PORT, **kwargs) + return client.RegistryClient(_CLIENT_HOST, _CLIENT_PORT, + _METADATA_ENCRYPTION_KEY, **kwargs) def get_images_list(context, **kwargs): diff --git a/glance/registry/client.py b/glance/registry/client.py index b001cb8a12..9828e0caa9 100644 --- a/glance/registry/client.py +++ b/glance/registry/client.py @@ -24,6 +24,7 @@ import json import urllib from glance.common.client import BaseClient +from glance.common import crypt from glance.registry.api.v1 import images @@ -33,6 +34,32 @@ class RegistryClient(BaseClient): DEFAULT_PORT = 9191 + def __init__(self, host=None, port=None, metadata_encryption_key=None, + **kwargs): + """ + :param metadata_encryption_key: Key used to encrypt 'location' metadata + """ + self.metadata_encryption_key = metadata_encryption_key + BaseClient.__init__(self, host, port, **kwargs) + + def decrypt_metadata(self, image_metadata): + if (self.metadata_encryption_key is not None + and 'location' in image_metadata.keys() + and image_metadata['location'] is not None): + location = crypt.urlsafe_decrypt(self.metadata_encryption_key, + image_metadata['location']) + image_metadata['location'] = location + return image_metadata + + def encrypt_metadata(self, image_metadata): + if (self.metadata_encryption_key is not None + and 'location' in image_metadata.keys() + and image_metadata['location'] is not None): + location = crypt.urlsafe_encrypt(self.metadata_encryption_key, + image_metadata['location'], 64) + image_metadata['location'] = location + return image_metadata + def get_images(self, **kwargs): """ Returns a list of image id/name mappings from Registry @@ -45,8 +72,10 @@ class RegistryClient(BaseClient): """ params = self._extract_params(kwargs, images.SUPPORTED_PARAMS) res = self.do_request("GET", "/images", params=params) - data = json.loads(res.read())['images'] - return data + image_list = json.loads(res.read())['images'] + for image in image_list: + image = self.decrypt_metadata(image) + return image_list def get_images_detailed(self, **kwargs): """ @@ -60,14 +89,16 @@ class RegistryClient(BaseClient): """ params = self._extract_params(kwargs, images.SUPPORTED_PARAMS) res = self.do_request("GET", "/images/detail", params=params) - data = json.loads(res.read())['images'] - return data + image_list = json.loads(res.read())['images'] + for image in image_list: + image = self.decrypt_metadata(image) + return image_list def get_image(self, image_id): """Returns a mapping of image metadata from Registry""" res = self.do_request("GET", "/images/%s" % image_id) data = json.loads(res.read())['image'] - return data + return self.decrypt_metadata(data) def add_image(self, image_metadata): """ @@ -80,12 +111,15 @@ class RegistryClient(BaseClient): if 'image' not in image_metadata.keys(): image_metadata = dict(image=image_metadata) + image_metadata['image'] = self.encrypt_metadata( + image_metadata['image']) body = json.dumps(image_metadata) res = self.do_request("POST", "/images", body, headers=headers) # Registry returns a JSONified dict(image=image_info) data = json.loads(res.read()) - return data['image'] + image = data['image'] + return self.decrypt_metadata(image) def update_image(self, image_id, image_metadata, purge_props=False): """ @@ -94,6 +128,8 @@ class RegistryClient(BaseClient): if 'image' not in image_metadata.keys(): image_metadata = dict(image=image_metadata) + image_metadata['image'] = self.encrypt_metadata( + image_metadata['image']) body = json.dumps(image_metadata) headers = { @@ -106,7 +142,7 @@ class RegistryClient(BaseClient): res = self.do_request("PUT", "/images/%s" % image_id, body, headers) data = json.loads(res.read()) image = data['image'] - return image + return self.decrypt_metadata(image) def delete_image(self, image_id): """ diff --git a/glance/tests/functional/__init__.py b/glance/tests/functional/__init__.py index 18741dac1b..fce6447345 100644 --- a/glance/tests/functional/__init__.py +++ b/glance/tests/functional/__init__.py @@ -150,6 +150,7 @@ class ApiServer(Server): self.default_store = 'file' self.key_file = "" self.cert_file = "" + self.metadata_encryption_key = "012345678901234567890123456789ab" self.image_dir = os.path.join(self.test_dir, "images") self.pid_file = os.path.join(self.test_dir, @@ -187,6 +188,7 @@ bind_host = 0.0.0.0 bind_port = %(bind_port)s key_file = %(key_file)s cert_file = %(cert_file)s +metadata_encryption_key = %(metadata_encryption_key)s registry_host = 0.0.0.0 registry_port = %(registry_port)s log_file = %(log_file)s diff --git a/glance/tests/functional/test_s3.py b/glance/tests/functional/test_s3.py index 085a8add78..4f22b92839 100644 --- a/glance/tests/functional/test_s3.py +++ b/glance/tests/functional/test_s3.py @@ -39,6 +39,7 @@ import unittest import httplib2 +from glance.common import crypt from glance.common import utils from glance.tests.functional import test_api from glance.tests.utils import execute, skip_if_disabled @@ -193,7 +194,12 @@ class TestS3(test_api.TestApi): http = httplib2.Http() response, content = http.request(path % args, 'GET') - s3_store_location = json.loads(content)['image']['location'] + if hasattr(self, 'metadata_encryption_key'): + key = self.metadata_encryption_key + else: + key = self.api_server.metadata_encryption_key + loc = json.loads(content)['image']['location'] + s3_store_location = crypt.urlsafe_decrypt(key, loc) # 4. POST /images using location generated by Image1 image_id2 = utils.generate_uuid() diff --git a/glance/tests/functional/test_swift.py b/glance/tests/functional/test_swift.py index a053835518..5242147b48 100644 --- a/glance/tests/functional/test_swift.py +++ b/glance/tests/functional/test_swift.py @@ -39,6 +39,7 @@ import os import tempfile import unittest +from glance.common import crypt import glance.store.swift # Needed to register driver for location from glance.store.location import get_location_from_uri from glance.tests.functional import test_api @@ -263,7 +264,13 @@ class TestSwift(test_api.TestApi): response, content = http.request(path, 'GET') self.assertEqual(response.status, 200) data = json.loads(content) - image_loc = get_location_from_uri(data['image']['location']) + image_loc = data['image']['location'] + if hasattr(self, 'metadata_encryption_key'): + key = self.metadata_encryption_key + else: + key = self.api_server.metadata_encryption_key + image_loc = crypt.urlsafe_decrypt(key, image_loc) + image_loc = get_location_from_uri(image_loc) swift_loc = image_loc.store_location from swift.common import client as swift_client @@ -449,7 +456,12 @@ class TestSwift(test_api.TestApi): self.assertEqual(response.status, 200) data = json.loads(content) self.assertTrue('location' in data['image'].keys()) - swift_location = data['image']['location'] + loc = data['image']['location'] + if hasattr(self, 'metadata_encryption_key'): + key = self.metadata_encryption_key + else: + key = self.api_server.metadata_encryption_key + swift_location = crypt.urlsafe_decrypt(key, loc) # POST /images with public image named Image1 without uploading data image_data = "*" * FIVE_KB diff --git a/glance/tests/unit/test_misc.py b/glance/tests/unit/test_misc.py index 646aca2ebf..d5039f51bf 100644 --- a/glance/tests/unit/test_misc.py +++ b/glance/tests/unit/test_misc.py @@ -21,6 +21,7 @@ import datetime import re import unittest +from glance.common import crypt from glance.common import exception from glance.common import utils @@ -126,3 +127,21 @@ class UtilsTestCase(unittest.TestCase): iso_re = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z') now_iso = utils.isotime() self.assertTrue(iso_re.match(now_iso) is not None) + + def test_encryption(self): + # Check that original plaintext and unencrypted ciphertext match + # Check keys of the three allowed lengths + key_list = ["1234567890abcdef", + "12345678901234567890abcd", + "1234567890abcdef1234567890ABCDEF"] + plaintext_list = [''] + blocksize = 64 + for i in range(3 * blocksize): + plaintext_list.append(os.urandom(i)) + + for key in key_list: + for plaintext in plaintext_list: + ciphertext = crypt.urlsafe_encrypt(key, plaintext, blocksize) + self.assertTrue(ciphertext != plaintext) + text = crypt.urlsafe_decrypt(key, ciphertext) + self.assertTrue(plaintext == text) diff --git a/tools/pip-requires b/tools/pip-requires index aede74c699..6c686ee7e0 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -20,3 +20,4 @@ bzr httplib2 xattr>=0.6.0 kombu +pycrypto