Skip to content

Commit

Permalink
Adds option to encrypt 'location' metadata.
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Stuart McLaren committed Nov 3, 2011
1 parent 2674e8b commit 5e6fb33
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 14 deletions.
1 change: 1 addition & 0 deletions Authors
Expand Up @@ -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>
Expand Down
7 changes: 7 additions & 0 deletions etc/glance-api.conf
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions 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))]
11 changes: 7 additions & 4 deletions glance/registry/__init__.py
Expand Up @@ -30,6 +30,8 @@
_CLIENT_HOST = None
_CLIENT_PORT = None
_CLIENT_KWARGS = {}
# AES key used to encrypt 'location' metadata
_METADATA_ENCRYPTION_KEY = None


def configure_registry_client(options):
Expand All @@ -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'])
Expand All @@ -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,
Expand All @@ -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):
Expand Down
50 changes: 43 additions & 7 deletions glance/registry/client.py
Expand Up @@ -24,6 +24,7 @@
import urllib

from glance.common.client import BaseClient
from glance.common import crypt
from glance.registry.api.v1 import images


Expand All @@ -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
Expand All @@ -45,8 +72,10 @@ def get_images(self, **kwargs):
"""
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):
"""
Expand All @@ -60,14 +89,16 @@ def get_images_detailed(self, **kwargs):
"""
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):
"""
Expand All @@ -80,12 +111,15 @@ def add_image(self, image_metadata):
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):
"""
Expand All @@ -94,6 +128,8 @@ def update_image(self, image_id, image_metadata, purge_props=False):
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 = {
Expand All @@ -106,7 +142,7 @@ def update_image(self, image_id, image_metadata, purge_props=False):
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):
"""
Expand Down
2 changes: 2 additions & 0 deletions glance/tests/functional/__init__.py
Expand Up @@ -150,6 +150,7 @@ def __init__(self, test_dir, port, registry_port, delayed_delete=False):
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,
Expand Down Expand Up @@ -187,6 +188,7 @@ def __init__(self, test_dir, port, registry_port, delayed_delete=False):
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
Expand Down
8 changes: 7 additions & 1 deletion glance/tests/functional/test_s3.py
Expand Up @@ -39,6 +39,7 @@

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
Expand Down Expand Up @@ -193,7 +194,12 @@ def test_remote_image(self):

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()
Expand Down
16 changes: 14 additions & 2 deletions glance/tests/functional/test_swift.py
Expand Up @@ -39,6 +39,7 @@
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
Expand Down Expand Up @@ -263,7 +264,13 @@ def test_large_objects(self):
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
Expand Down Expand Up @@ -449,7 +456,12 @@ def test_remote_image(self):
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
Expand Down
19 changes: 19 additions & 0 deletions glance/tests/unit/test_misc.py
Expand Up @@ -21,6 +21,7 @@
import re
import unittest

from glance.common import crypt
from glance.common import exception
from glance.common import utils

Expand Down Expand Up @@ -126,3 +127,21 @@ def test_isotime(self):
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)
1 change: 1 addition & 0 deletions tools/pip-requires
Expand Up @@ -20,3 +20,4 @@ bzr
httplib2
xattr>=0.6.0
kombu
pycrypto

0 comments on commit 5e6fb33

Please sign in to comment.