Adds option to encrypt 'location' metadata.
Implements blueprint swift-location-credentials When the new option is enabled the location metadata (which may include user credentials) is encrypted before being sent to the registry server. Change-Id: I072e3f5c81f187435b1c156627076d5fde087af5
This commit is contained in:
parent
2674e8bbbc
commit
5e6fb33b22
1
Authors
1
Authors
@ -26,6 +26,7 @@ Monty Taylor <mordred@inaugust.com>
|
||||
Rick Clark <rick@openstack.org>
|
||||
Rick Harris <rconradharris@gmail.com>
|
||||
Soren Hansen <soren.hansen@rackspace.com>
|
||||
Stuart McLaren <stuart.mclaren@hp.com>
|
||||
Taku Fukushima <tfukushima@dcl.info.waseda.ac.jp>
|
||||
Thierry Carrez <thierry@openstack.org>
|
||||
Tom Hancock <tom.hancock@hp.com>
|
||||
|
@ -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
|
||||
|
70
glance/common/crypt.py
Normal file
70
glance/common/crypt.py
Normal file
@ -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))]
|
@ -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):
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -20,3 +20,4 @@ bzr
|
||||
httplib2
|
||||
xattr>=0.6.0
|
||||
kombu
|
||||
pycrypto
|
||||
|
Loading…
Reference in New Issue
Block a user