File keystoneclient-fix-memcache-enryption-middleware.patch of Package python-keystoneclient
--- a/keystoneclient/middleware/auth_token.py
+++ b/keystoneclient/middleware/auth_token.py
@@ -221,6 +221,7 @@ opts = [
CONF.register_opts(opts, group='keystone_authtoken')
LIST_OF_VERSIONS_TO_ATTEMPT = ['v2.0', 'v3.0']
+CACHE_KEY_TEMPLATE = 'tokens/%s'
def will_expire_soon(expiry):
@@ -842,91 +843,79 @@ class AuthProtocol(object):
env_key = self._header_to_env_var(key)
return env.get(env_key, default)
- def _protect_cache_value(self, token, data):
- """ Encrypt or sign data if necessary. """
- try:
- if self._memcache_security_strategy == 'ENCRYPT':
- return memcache_crypt.encrypt_data(token,
- self._memcache_secret_key,
- data)
- elif self._memcache_security_strategy == 'MAC':
- return memcache_crypt.sign_data(token, data)
- else:
- return data
- except:
- msg = 'Failed to encrypt/sign cache data.'
- self.LOG.exception(msg)
- return data
-
- def _unprotect_cache_value(self, token, data):
- """ Decrypt or verify signed data if necessary. """
- if data is None:
- return data
-
- try:
- if self._memcache_security_strategy == 'ENCRYPT':
- return memcache_crypt.decrypt_data(token,
- self._memcache_secret_key,
- data)
- elif self._memcache_security_strategy == 'MAC':
- return memcache_crypt.verify_signed_data(token, data)
- else:
- return data
- except:
- msg = 'Failed to decrypt/verify cache data.'
- self.LOG.exception(msg)
- # this should have the same effect as data not found in cache
- return None
-
- def _get_cache_key(self, token):
- """ Return the cache key.
-
- Do not use clear token as key if memcache protection is on.
-
- """
- htoken = token
- if self._memcache_security_strategy in ('ENCRYPT', 'MAC'):
- derv_token = token + self._memcache_secret_key
- htoken = memcache_crypt.hash_data(derv_token)
- return 'tokens/%s' % htoken
-
- def _cache_get(self, token):
+ def _cache_get(self, token, ignore_expires=False):
"""Return token information from cache.
If token is invalid raise InvalidUserToken
return token only if fresh (not expired).
"""
if self._cache and token:
- key = self._get_cache_key(token)
- cached = self._cache.get(key)
- cached = self._unprotect_cache_value(token, cached)
+ if self._memcache_security_strategy is None:
+ key = CACHE_KEY_TEMPLATE % token
+ serialized = self._cache.get(key)
+ else:
+ keys = memcache_crypt.derive_keys(
+ token,
+ self._memcache_secret_key,
+ self._memcache_security_strategy)
+ cache_key = CACHE_KEY_TEMPLATE % (
+ memcache_crypt.get_cache_key(keys))
+ raw_cached = self._cache.get(cache_key)
+ try:
+ # unprotect_data will return None if raw_cached is None
+ serialized = memcache_crypt.unprotect_data(keys,
+ raw_cached)
+ except Exception:
+ msg = 'Failed to decrypt/verify cache data'
+ self.LOG.exception(msg)
+ # this should have the same effect as data not
+ # found in cache
+ serialized = None
+
+ if serialized is None:
+ return None
+
+ # Note that 'invalid' and (data, expires) are the only
+ # valid types of serialized cache entries, so there is not
+ # a collision with json.loads(serialized) == None.
+ cached = json.loads(serialized)
if cached == 'invalid':
self.LOG.debug('Cached Token %s is marked unauthorized', token)
raise InvalidUserToken('Token authorization failed')
- if cached:
- data, expires = cached
- if time.time() < float(expires):
- self.LOG.debug('Returning cached token %s', token)
- return data
- else:
- self.LOG.debug('Cached Token %s seems expired', token)
-
- def _cache_store(self, token, data, expires=None):
- """ Store value into memcache. """
- key = self._get_cache_key(token)
- data = self._protect_cache_value(token, data)
- data_to_store = data
- if expires:
- data_to_store = (data, expires)
+ data, expires = cached
+ if ignore_expires or time.time() < float(expires):
+ self.LOG.debug('Returning cached token %s', token)
+ return data
+ else:
+ self.LOG.debug('Cached Token %s seems expired', token)
+
+ def _cache_store(self, token, data):
+ """ Store value into memcache.
+
+ data may be the string 'invalid' or a tuple like (data, expires)
+
+ """
+ serialized_data = json.dumps(data)
+ if self._memcache_security_strategy is None:
+ cache_key = CACHE_KEY_TEMPLATE % token
+ data_to_store = serialized_data
+ else:
+ keys = memcache_crypt.derive_keys(
+ token,
+ self._memcache_secret_key,
+ self._memcache_security_strategy)
+ cache_key = CACHE_KEY_TEMPLATE % memcache_crypt.get_cache_key(keys)
+ data_to_store = memcache_crypt.protect_data(keys, serialized_data)
+
# we need to special-case set() because of the incompatibility between
# Swift MemcacheRing and python-memcached. See
# https://bugs.launchpad.net/swift/+bug/1095730
if self._use_keystone_cache:
- self._cache.set(key,
+ self._cache.set(cache_key,
data_to_store,
time=self.token_cache_time)
else:
- self._cache.set(key,
+ self._cache.set(cache_key,
data_to_store,
timeout=self.token_cache_time)
@@ -946,7 +935,7 @@ class AuthProtocol(object):
return
expires = timeutils.parse_isotime(timestamp).strftime('%s')
self.LOG.debug('Storing %s token in memcache', token)
- self._cache_store(token, data, expires)
+ self._cache_store(token, (data, expires))
def _cache_store_invalid(self, token):
"""Store invalid token in cache."""
--- a/keystoneclient/middleware/memcache_crypt.py
+++ b/keystoneclient/middleware/memcache_crypt.py
@@ -1,6 +1,6 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
-# Copyright 2010-2012 OpenStack LLC
+# Copyright 2010-2013 OpenStack LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,33 +18,34 @@
"""
Utilities for memcache encryption and integrity check.
-Data is serialized before been encrypted or MACed. Encryption have a
-dependency on the pycrypto. If pycrypto is not available,
-CryptoUnabailableError will be raised.
-
-Encrypted data stored in memcache are prefixed with '{ENCRYPT:AES256}'.
-
-MACed data stored in memcache are prefixed with '{MAC:SHA1}'.
+Data should be serialized before entering these functions. Encryption
+has a dependency on the pycrypto. If pycrypto is not available,
+CryptoUnavailableError will be raised.
+
+This module will not be called unless signing or encryption is enabled
+in the config. It will always validate signatures, and will decrypt
+data if encryption is enabled. It is not valid to mix protection
+modes.
"""
import base64
import functools
import hashlib
-import json
+import hmac
+import math
import os
-# make sure pycrypt is available
+# make sure pycrypto is available
try:
from Crypto.Cipher import AES
except ImportError:
AES = None
-
-# prefix marker indicating data is HMACed (signed by a secret key)
-MAC_MARKER = '{MAC:SHA1}'
-# prefix marker indicating data is encrypted
-ENCRYPT_MARKER = '{ENCRYPT:AES256}'
+HASH_FUNCTION = hashlib.sha384
+DIGEST_LENGTH = HASH_FUNCTION().digest_size
+DIGEST_SPLIT = DIGEST_LENGTH // 3
+DIGEST_LENGTH_B64 = 4 * int(math.ceil(DIGEST_LENGTH / 3.0))
class InvalidMacError(Exception):
@@ -81,77 +82,121 @@ def assert_crypto_availability(f):
return wrapper
-def generate_aes_key(token, secret):
- """ Generates and returns a 256 bit AES key, based on sha256 hash. """
- return hashlib.sha256(token + secret).digest()
-
-
-def compute_mac(token, serialized_data):
- """ Computes and returns the base64 encoded MAC. """
- return hash_data(serialized_data + token)
-
-
-def hash_data(data):
- """ Return the base64 encoded SHA1 hash of the data. """
- return base64.b64encode(hashlib.sha1(data).digest())
-
-
-def sign_data(token, data):
- """ MAC the data using SHA1. """
- mac_data = {}
- mac_data['serialized_data'] = json.dumps(data)
- mac = compute_mac(token, mac_data['serialized_data'])
- mac_data['mac'] = mac
- md = MAC_MARKER + base64.b64encode(json.dumps(mac_data))
- return md
-
-
-def verify_signed_data(token, data):
- """ Verify data integrity by ensuring MAC is valid. """
- if data.startswith(MAC_MARKER):
- try:
- data = data[len(MAC_MARKER):]
- mac_data = json.loads(base64.b64decode(data))
- mac = compute_mac(token, mac_data['serialized_data'])
- if mac != mac_data['mac']:
- raise InvalidMacError('invalid MAC; expect=%s, actual=%s' %
- (mac_data['mac'], mac))
- return json.loads(mac_data['serialized_data'])
- except:
- raise InvalidMacError('invalid MAC; data appeared to be corrupted')
- else:
- # doesn't appear to be MACed data
- return data
+def constant_time_compare(first, second):
+ """ Returns True if both string inputs are equal, otherwise False
+
+ This function should take a constant amount of time regardless of
+ how many characters in the strings match.
+ """
+
+ if len(first) != len(second):
+ return False
+ result = 0
+ for x, y in zip(first, second):
+ result |= ord(x) ^ ord(y)
+ return result == 0
+
+
+def derive_keys(token, secret, strategy):
+ """ Derives keys for MAC and ENCRYPTION from the user-provided
+ secret. The resulting keys should be passed to the protect and
+ unprotect functions.
+
+ As suggested by NIST Special Publication 800-108, this uses the
+ first 128 bits from the sha384 KDF for the obscured cache key
+ value, the second 128 bits for the message authentication key and
+ the remaining 128 bits for the encryption key.
+
+ This approach is faster than computing a separate hmac as the KDF
+ for each desired key.
+ """
+ digest = hmac.new(secret, token + strategy, HASH_FUNCTION).digest()
+ return {'CACHE_KEY': digest[:DIGEST_SPLIT],
+ 'MAC': digest[DIGEST_SPLIT: 2 * DIGEST_SPLIT],
+ 'ENCRYPTION': digest[2 * DIGEST_SPLIT:],
+ 'strategy': strategy}
+
+
+def sign_data(key, data):
+ """ Sign the data using the defined function and the derived key"""
+ mac = hmac.new(key, data, HASH_FUNCTION).digest()
+ return base64.b64encode(mac)
@assert_crypto_availability
-def encrypt_data(token, secret, data):
- """ Encryptes the data with the given secret key. """
+def encrypt_data(key, data):
+ """ Encrypt the data with the given secret key.
+
+ Padding is n bytes of the value n, where 1 <= n <= blocksize.
+ """
iv = os.urandom(16)
- aes_key = generate_aes_key(token, secret)
- cipher = AES.new(aes_key, AES.MODE_CFB, iv)
- data = json.dumps(data)
- encoded_data = base64.b64encode(iv + cipher.encrypt(data))
- encoded_data = ENCRYPT_MARKER + encoded_data
- return encoded_data
+ cipher = AES.new(key, AES.MODE_CBC, iv)
+ padding = 16 - len(data) % 16
+ return iv + cipher.encrypt(data + chr(padding) * padding)
@assert_crypto_availability
-def decrypt_data(token, secret, data):
+def decrypt_data(key, data):
""" Decrypt the data with the given secret key. """
- if data.startswith(ENCRYPT_MARKER):
- try:
- # encrypted data
- encoded_data = data[len(ENCRYPT_MARKER):]
- aes_key = generate_aes_key(token, secret)
- decoded_data = base64.b64decode(encoded_data)
- iv = decoded_data[:16]
- encrypted_data = decoded_data[16:]
- cipher = AES.new(aes_key, AES.MODE_CFB, iv)
- decrypted_data = cipher.decrypt(encrypted_data)
- return json.loads(decrypted_data)
- except:
- raise DecryptError('data appeared to be corrupted')
- else:
- # doesn't appear to be encrypted data
- return data
+ iv = data[:16]
+ cipher = AES.new(key, AES.MODE_CBC, iv)
+ try:
+ result = cipher.decrypt(data[16:])
+ except Exception:
+ raise DecryptError('Encrypted data appears to be corrupted.')
+
+ # Strip the last n padding bytes where n is the last value in
+ # the plaintext
+ padding = ord(result[-1])
+ return result[:-1 * padding]
+
+
+def protect_data(keys, data):
+ """ Given keys and serialized data, returns an appropriately
+ protected string suitable for storage in the cache.
+
+ """
+ if keys['strategy'] == 'ENCRYPT':
+ data = encrypt_data(keys['ENCRYPTION'], data)
+
+ encoded_data = base64.b64encode(data)
+
+ signature = sign_data(keys['MAC'], encoded_data)
+ return signature + encoded_data
+
+
+def unprotect_data(keys, signed_data):
+ """ Given keys and cached string data, verifies the signature,
+ decrypts if necessary, and returns the original serialized data.
+
+ """
+ # cache backends return None when no data is found. We don't mind
+ # that this particular special value is unsigned.
+ if signed_data is None:
+ return None
+
+ # First we calculate the signature
+ provided_mac = signed_data[:DIGEST_LENGTH_B64]
+ calculated_mac = sign_data(
+ keys['MAC'],
+ signed_data[DIGEST_LENGTH_B64:])
+
+ # Then verify that it matches the provided value
+ if not constant_time_compare(provided_mac, calculated_mac):
+ raise InvalidMacError('Invalid MAC; data appears to be corrupted.')
+
+ data = base64.b64decode(signed_data[DIGEST_LENGTH_B64:])
+
+ # then if necessary decrypt the data
+ if keys['strategy'] == 'ENCRYPT':
+ data = decrypt_data(keys['ENCRYPTION'], data)
+
+ return data
+
+
+def get_cache_key(keys):
+ """ Given keys generated by derive_keys(), returns a base64
+ encoded value suitable for use as a cache key in memcached.
+
+ """
+ return base64.b64encode(keys['CACHE_KEY'])
--- a/tests/test_auth_token_middleware.py
+++ b/tests/test_auth_token_middleware.py
@@ -28,7 +28,6 @@ import webob
from keystoneclient.common import cms
from keystoneclient import utils
from keystoneclient.middleware import auth_token
-from keystoneclient.middleware import memcache_crypt
from keystoneclient.openstack.common import memorycache
from keystoneclient.openstack.common import jsonutils
from keystoneclient.openstack.common import timeutils
@@ -932,9 +931,7 @@ class AuthTokenMiddlewareTest(test.NoMod
def _get_cached_token(self, token):
token_id = cms.cms_hash_token(token)
# NOTE(vish): example tokens are expired so skip the expiration check.
- key = self.middleware._get_cache_key(token_id)
- cached = self.middleware._cache.get(key)
- return self.middleware._unprotect_cache_value(token, cached)
+ return self.middleware._cache_get(token_id, ignore_expires=True)
def test_memcache(self):
req = webob.Request.blank('/')
@@ -948,7 +945,8 @@ class AuthTokenMiddlewareTest(test.NoMod
token = 'invalid-token'
req.headers['X-Auth-Token'] = token
self.middleware(req.environ, self.start_fake_response)
- self.assertEqual(self._get_cached_token(token), "invalid")
+ self.assertRaises(auth_token.InvalidUserToken,
+ self._get_cached_token, token)
def test_memcache_set_expired(self):
token_cache_time = 10
@@ -1031,18 +1029,11 @@ class AuthTokenMiddlewareTest(test.NoMod
'memcache_secret_key': 'mysecret'
}
self.set_middleware(conf=conf)
- encrypted_data = self.middleware._protect_cache_value(
- 'token', TOKEN_RESPONSES[self.token_dict['uuid_token_default']])
- self.assertEqual('{ENCRYPT:AES256}', encrypted_data[:16])
- self.assertEqual(
- TOKEN_RESPONSES[self.token_dict['uuid_token_default']],
- self.middleware._unprotect_cache_value('token', encrypted_data))
- # should return None if unable to decrypt
- self.assertIsNone(
- self.middleware._unprotect_cache_value(
- 'token', '{ENCRYPT:AES256}corrupted'))
- self.assertIsNone(
- self.middleware._unprotect_cache_value('mykey', encrypted_data))
+ token = 'my_token'
+ data = ('this_data', 10e100)
+ self.middleware._init_cache({})
+ self.middleware._cache_store(token, data)
+ self.assertEqual(self.middleware._cache_get(token), data[0])
def test_sign_cache_data(self):
conf = {
@@ -1054,19 +1045,11 @@ class AuthTokenMiddlewareTest(test.NoMod
'memcache_secret_key': 'mysecret'
}
self.set_middleware(conf=conf)
- signed_data = self.middleware._protect_cache_value(
- 'mykey', TOKEN_RESPONSES[self.token_dict['uuid_token_default']])
- expected = '{MAC:SHA1}'
- self.assertEqual(
- signed_data[:10],
- expected)
- self.assertEqual(
- TOKEN_RESPONSES[self.token_dict['uuid_token_default']],
- self.middleware._unprotect_cache_value('mykey', signed_data))
- # should return None on corrupted data
- self.assertIsNone(
- self.middleware._unprotect_cache_value('mykey',
- '{MAC:SHA1}corrupted'))
+ token = 'my_token'
+ data = ('this_data', 10e100)
+ self.middleware._init_cache({})
+ self.middleware._cache_store(token, data)
+ self.assertEqual(self.middleware._cache_get(token), data[0])
def test_no_memcache_protection(self):
conf = {
@@ -1077,47 +1060,11 @@ class AuthTokenMiddlewareTest(test.NoMod
'memcache_secret_key': 'mysecret'
}
self.set_middleware(conf=conf)
- data = self.middleware._protect_cache_value('mykey',
- 'This is a test!')
- self.assertEqual(data, 'This is a test!')
- self.assertEqual(
- 'This is a test!',
- self.middleware._unprotect_cache_value('mykey', data))
-
- def test_get_cache_key(self):
- conf = {
- 'auth_host': 'keystone.example.com',
- 'auth_port': 1234,
- 'auth_admin_prefix': '/testadmin',
- 'memcache_servers': ['localhost:11211'],
- 'memcache_secret_key': 'mysecret'
- }
- self.set_middleware(conf=conf)
- self.assertEqual(
- 'tokens/mytoken',
- self.middleware._get_cache_key('mytoken'))
- conf = {
- 'auth_host': 'keystone.example.com',
- 'auth_port': 1234,
- 'auth_admin_prefix': '/testadmin',
- 'memcache_servers': ['localhost:11211'],
- 'memcache_security_strategy': 'mac',
- 'memcache_secret_key': 'mysecret'
- }
- self.set_middleware(conf=conf)
- expected = 'tokens/' + memcache_crypt.hash_data('mytoken' + 'mysecret')
- self.assertEqual(self.middleware._get_cache_key('mytoken'), expected)
- conf = {
- 'auth_host': 'keystone.example.com',
- 'auth_port': 1234,
- 'auth_admin_prefix': '/testadmin',
- 'memcache_servers': ['localhost:11211'],
- 'memcache_security_strategy': 'Encrypt',
- 'memcache_secret_key': 'abc!'
- }
- self.set_middleware(conf=conf)
- expected = 'tokens/' + memcache_crypt.hash_data('mytoken' + 'abc!')
- self.assertEqual(self.middleware._get_cache_key('mytoken'), expected)
+ token = 'my_token'
+ data = ('this_data', 10e100)
+ self.middleware._init_cache({})
+ self.middleware._cache_store(token, data)
+ self.assertEqual(self.middleware._cache_get(token), data[0])
def test_assert_valid_memcache_protection_config(self):
# test missing memcache_secret_key
--- a/tests/test_memcache_crypt.py
+++ b/tests/test_memcache_crypt.py
@@ -4,48 +4,66 @@ from keystoneclient.middleware import me
class MemcacheCryptPositiveTests(testtools.TestCase):
- def test_generate_aes_key(self):
- self.assertEqual(
- len(memcache_crypt.generate_aes_key('Gimme Da Key', 'hush')), 32)
-
- def test_compute_mac(self):
- self.assertEqual(
- memcache_crypt.compute_mac('mykey', 'This is a test!'),
- 'tREu41yR5tEgeBWIuv9ag4AeKA8=')
+ def _setup_keys(self, strategy):
+ return memcache_crypt.derive_keys('token', 'secret', strategy)
+
+ def test_constant_time_compare(self):
+ # make sure it works as a compare, the "constant time" aspect
+ # isn't appropriate to test in unittests
+ ctc = memcache_crypt.constant_time_compare
+ self.assertTrue(ctc('abcd', 'abcd'))
+ self.assertTrue(ctc('', ''))
+ self.assertFalse(ctc('abcd', 'efgh'))
+ self.assertFalse(ctc('abc', 'abcd'))
+ self.assertFalse(ctc('abc', 'abc\x00'))
+ self.assertFalse(ctc('', 'abc'))
+
+ def test_derive_keys(self):
+ keys = memcache_crypt.derive_keys('token', 'secret', 'strategy')
+ self.assertEqual(len(keys['ENCRYPTION']),
+ len(keys['CACHE_KEY']))
+ self.assertEqual(len(keys['CACHE_KEY']),
+ len(keys['MAC']))
+ self.assertNotEqual(keys['ENCRYPTION'],
+ keys['MAC'])
+ self.assertIn('strategy', keys.keys())
+
+ def test_key_strategy_diff(self):
+ k1 = self._setup_keys('MAC')
+ k2 = self._setup_keys('ENCRYPT')
+ self.assertNotEqual(k1, k2)
def test_sign_data(self):
- expected = '{MAC:SHA1}eyJtYWMiOiAiM0FrQmdPZHRybGo1RFFESHA1eUxqcDVq' +\
- 'Si9BPSIsICJzZXJpYWxpemVkX2RhdGEiOiAiXCJUaGlzIGlzIGEgdG' +\
- 'VzdCFcIiJ9'
- self.assertEqual(
- memcache_crypt.sign_data('mykey', 'This is a test!'),
- expected)
-
- def test_verify_signed_data(self):
- signed = memcache_crypt.sign_data('mykey', 'Testz')
- self.assertEqual(
- memcache_crypt.verify_signed_data('mykey', signed),
- 'Testz')
- self.assertEqual(
- memcache_crypt.verify_signed_data('aasSFWE13WER', 'not MACed'),
- 'not MACed')
-
- def test_encrypt_data(self):
- expected = '{ENCRYPT:AES256}'
- self.assertEqual(
- memcache_crypt.encrypt_data('mykey', 'mysecret',
- 'This is a test!')[:16],
- expected)
-
- def test_decrypt_data(self):
- encrypted = memcache_crypt.encrypt_data('mykey', 'mysecret', 'Testz')
- self.assertEqual(
- memcache_crypt.decrypt_data('mykey', 'mysecret', encrypted),
- 'Testz')
- self.assertEqual(
- memcache_crypt.decrypt_data('mykey', 'mysecret',
- 'Not Encrypted!'),
- 'Not Encrypted!')
+ keys = self._setup_keys('MAC')
+ sig = memcache_crypt.sign_data(keys['MAC'], 'data')
+ self.assertEqual(len(sig), memcache_crypt.DIGEST_LENGTH_B64)
+
+ def test_encryption(self):
+ keys = self._setup_keys('ENCRYPT')
+ # what you put in is what you get out
+ for data in ['data', '1234567890123456', '\x00\xFF' * 13
+ ] + [chr(x % 256) * x for x in range(768)]:
+ crypt = memcache_crypt.encrypt_data(keys['ENCRYPTION'], data)
+ decrypt = memcache_crypt.decrypt_data(keys['ENCRYPTION'], crypt)
+ self.assertEqual(data, decrypt)
+ self.assertRaises(memcache_crypt.DecryptError,
+ memcache_crypt.decrypt_data,
+ keys['ENCRYPTION'], crypt[:-1])
+
+ def test_protect_wrappers(self):
+ data = 'My Pretty Little Data'
+ for strategy in ['MAC', 'ENCRYPT']:
+ keys = self._setup_keys(strategy)
+ protected = memcache_crypt.protect_data(keys, data)
+ self.assertNotEqual(protected, data)
+ if strategy == 'ENCRYPT':
+ self.assertNotIn(data, protected)
+ unprotected = memcache_crypt.unprotect_data(keys, protected)
+ self.assertEqual(data, unprotected)
+ self.assertRaises(memcache_crypt.InvalidMacError,
+ memcache_crypt.unprotect_data,
+ keys, protected[:-1])
+ self.assertIsNone(memcache_crypt.unprotect_data(keys, None))
def test_no_pycrypt(self):
aes = memcache_crypt.AES