diff options
-rw-r--r-- | etc/keystone-paste.ini | 5 | ||||
-rw-r--r-- | etc/keystone.conf.sample | 7 | ||||
-rw-r--r-- | keystone/contrib/kds/__init__.py | 19 | ||||
-rw-r--r-- | keystone/contrib/kds/backends/__init__.py | 0 | ||||
-rw-r--r-- | keystone/contrib/kds/backends/sql.py | 68 | ||||
-rw-r--r-- | keystone/contrib/kds/controllers.py | 31 | ||||
-rw-r--r-- | keystone/contrib/kds/core.py | 234 | ||||
-rw-r--r-- | keystone/contrib/kds/migrate_repo/__init__.py | 0 | ||||
-rw-r--r-- | keystone/contrib/kds/migrate_repo/migrate.cfg | 20 | ||||
-rw-r--r-- | keystone/contrib/kds/migrate_repo/versions/001_kds_table.py | 37 | ||||
-rw-r--r-- | keystone/contrib/kds/migrate_repo/versions/__init__.py | 0 | ||||
-rw-r--r-- | keystone/contrib/kds/routers.py | 39 | ||||
-rw-r--r-- | keystone/service.py | 2 | ||||
-rw-r--r-- | keystone/tests/core.py | 1 | ||||
-rw-r--r-- | keystone/tests/test_sql_migrate_extensions.py | 17 | ||||
-rw-r--r-- | requirements.txt | 1 |
16 files changed, 480 insertions, 1 deletions
diff --git a/etc/keystone-paste.ini b/etc/keystone-paste.ini index 9c5545db..fb66397e 100644 --- a/etc/keystone-paste.ini +++ b/etc/keystone-paste.ini @@ -45,6 +45,9 @@ paste.filter_factory = keystone.contrib.stats:StatsExtension.factory [filter:access_log] paste.filter_factory = keystone.contrib.access:AccessLogMiddleware.factory +[filter:kds_extension] +paste.filter_factory = keystone.contrib.kds.routers:KDSExtension.factory + [app:public_service] paste.app_factory = keystone.service:public_app_factory @@ -61,7 +64,7 @@ pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_bo pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body ec2_extension s3_extension crud_extension admin_service [pipeline:api_v3] -pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body ec2_extension s3_extension service_v3 +pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body ec2_extension s3_extension kds_extension service_v3 [app:public_version_service] paste.app_factory = keystone.service:public_version_app_factory diff --git a/etc/keystone.conf.sample b/etc/keystone.conf.sample index 13d14317..68af3f59 100644 --- a/etc/keystone.conf.sample +++ b/etc/keystone.conf.sample @@ -160,6 +160,13 @@ [ec2] # driver = keystone.contrib.ec2.backends.kvs.Ec2 +[kds] +# driver = keystone.contrib.kds.backends.sql.KDS +# master_key_file = /etc/keystone/kds.mkey +# enctype = AES +# hashtype = SHA256 +# ticket_lifetime = 3600 + [assignment] # driver = diff --git a/keystone/contrib/kds/__init__.py b/keystone/contrib/kds/__init__.py new file mode 100644 index 00000000..e5faf9e5 --- /dev/null +++ b/keystone/contrib/kds/__init__.py @@ -0,0 +1,19 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# flake8: noqa + +# Copyright 2012 OpenStack LLC +# +# 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. + +from keystone.contrib.kds.routers import * +from keystone.contrib.kds.core import * diff --git a/keystone/contrib/kds/backends/__init__.py b/keystone/contrib/kds/backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone/contrib/kds/backends/__init__.py diff --git a/keystone/contrib/kds/backends/sql.py b/keystone/contrib/kds/backends/sql.py new file mode 100644 index 00000000..b7878f2a --- /dev/null +++ b/keystone/contrib/kds/backends/sql.py @@ -0,0 +1,68 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Red Hat, Inc. +# +# 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. + +import base64 +import hashlib + +from keystone.common import sql + + +class Keys(sql.ModelBase, sql.DictBase): + __tablename__ = 'kds_keys' + attributes = ['id', 'name', 'key'] + id = sql.Column(sql.String(64), primary_key=True) + name = sql.Column(sql.Text()) + key = sql.Column(sql.JsonBlob()) + extra = sql.Column(sql.JsonBlob()) + + +class KDS(sql.Base): + + def _id_from_name(self, name): + return hashlib.sha256(name).hexdigest() + + @sql.handle_conflicts(type='kds_keys') + def set_shared_key(self, kds_id, key): + session = self.get_session() + + #try to remove existing entry first if any + try: + with session.begin(): + id = self._id_from_name(kds_id) + key_ref = session.query(Keys).filter_by(id=id).first() + session.delete(key_ref) + session.flush() + except Exception: + # if the entry does not exist we'll create it later + pass + + with session.begin(): + d = dict() + d['id'] = self._id_from_name(kds_id) + d['name'] = kds_id + d['key'] = {'key_v1': base64.b64encode(key)} + key_ref = Keys.from_dict(d) + session.add(key_ref) + session.flush() + + def get_shared_key(self, kds_id): + session = self.get_session() + id = self._id_from_name(kds_id) + key_ref = session.query(Keys).filter_by(id=id).first() + if not key_ref: + return None + d = key_ref.to_dict() + return base64.b64decode(d['key']['key_v1']) diff --git a/keystone/contrib/kds/controllers.py b/keystone/contrib/kds/controllers.py new file mode 100644 index 00000000..5af913d6 --- /dev/null +++ b/keystone/contrib/kds/controllers.py @@ -0,0 +1,31 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Red Hat, Inc. +# +# 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. + +from keystone.common import dependency +from keystone.common import wsgi + + +@dependency.requires('identity_api', 'kds_api', 'policy_api', 'token_api') +class KDSController(wsgi.Application): + def get_info(self, context): + return {'version': '0.0.1'} + + def get_ticket(self, context, signature, request): + return self.kds_api.get_ticket(signature, request) + + def set_key(self, context, name, request): + self.assert_admin(context) + return self.kds_api.set_key(name, request) diff --git a/keystone/contrib/kds/core.py b/keystone/contrib/kds/core.py new file mode 100644 index 00000000..d707482d --- /dev/null +++ b/keystone/contrib/kds/core.py @@ -0,0 +1,234 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Red Hat, Inc. +# +# 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. + +"""Main entry point into the Key distribution Server service.""" + +import base64 +import errno +import os +import time + +from keystone.openstack.common.crypto import utils as cryptoutils +from keystone.openstack.common import jsonutils +from oslo.config import cfg + +from keystone.common import dependency +from keystone.common import logging +from keystone.common import manager +from keystone import config +from keystone import exception + + +CONF = config.CONF +kds_opts = [ + cfg.StrOpt('driver', default='keystone.contrib.kds.backends.sql.KDS'), + cfg.StrOpt('master_key_file', default='/etc/keystone/kds.mkey'), + cfg.StrOpt('enctype', default='AES'), + cfg.StrOpt('hashtype', default='SHA256'), + cfg.IntOpt('ticket_lifetime', default='3600') +] +CONF.register_group(cfg.OptGroup(name='kds', + title='Key Distribution Server opts')) +CONF.register_opts(kds_opts, group='kds') + +LOG = logging.getLogger(__name__) + +KEY_SIZE = 16 + + +@dependency.provider('kds_api') +class Manager(manager.Manager): + """Default pivot point for the KDS backend. + + See :mod:`keystone.common.manager.Manager` for more details on how this + dynamically calls the backend. + + """ + + def __init__(self): + self.crypto = cryptoutils.SymmetricCrypto(enctype=CONF.kds.enctype, + hashtype=CONF.kds.hashtype) + self.hkdf = cryptoutils.HKDF(hashtype=CONF.kds.hashtype) + self.ttl = CONF.kds.ticket_lifetime + + super(Manager, self).__init__(CONF.kds.driver) + + try: + with open(CONF.kds.master_key_file, 'r') as f: + self.mkey = base64.b64decode(f.read()) + except IOError as e: + if e.errno == errno.ENOENT: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + try: + f = os.open(CONF.kds.master_key_file, flags, 0600) + self.mkey = self.crypto.new_key(KEY_SIZE) + os.write(f, base64.b64encode(self.mkey)) + except Exception: + os.remove(CONF.kds.master_key_file) + finally: + if f > 0: + os.close(f) + else: + # the file could be unreadbale due to bad permissions + # so just pop up whatever error comes + raise + + if len(self.mkey) != KEY_SIZE: + raise Exception('Invalid Master Key Size') + + def _split_key(self, key, size): + sig_key = key[:size] + enc_key = key[size:] + return sig_key, enc_key + + def _get_master_keys(self, key_id): + if not self.mkey: + raise exception.UnexpectedError('Failed to find mkey') + + km = self.hkdf.expand(self.mkey, key_id, 2 * KEY_SIZE) + return self._split_key(km, KEY_SIZE) + + def _encrypt_keyblock(self, key_id, keyblock): + skey, ekey = self._get_master_keys(key_id) + try: + enc = self.crypto.encrypt(ekey, keyblock, b64encode=False) + except Exception: + raise exception.UnexpectedError('Failed to encrypt key') + + try: + sig = self.crypto.sign(skey, enc, b64encode=False) + except Exception: + raise exception.UnexpectedError('Failed to sign key') + + return sig, enc + + def _decrypt_keyblock(self, key_id, keyblock): + sig, enc = self._split_key(keyblock, self.crypto.hashfn.digest_size) + skey, ekey = self._get_master_keys(key_id) + + # signature check + try: + sigc = self.crypto.sign(skey, enc, b64encode=False) + if not sigc == sig: + raise exception.UnexpectedError('Signature check failed') + except Exception: + raise exception.UnexpectedError('Failed to verify key') + + try: + plain = self.crypto.decrypt(ekey, enc, b64decode=False) + except Exception: + raise exception.UnexpectedError('Failed to decrypt key') + + return plain + + def _get_key(self, key_id): + """Decrypts the provided encoded key and returns + the clear text key. + + :param key_id: Key Identifier + """ + # Get Requestor's encypted key + key = self.driver.get_shared_key(key_id) + if not key: + raise exception.Unauthorized('Invalid Requestor') + + return self._decrypt_keyblock(key_id, key) + + def _set_key(self, key_id, keyblock): + """Encrypts the provided key and stores it. + + :param keyblock: The key to encrypt + """ + sig, enc = self._encrypt_keyblock(key_id, keyblock) + self.driver.set_shared_key(key_id, sig + enc) + + def get_ticket(self, sig, req): + if not ('metadata' in req): + raise exception.Forbidden('Invalid Request format') + try: + meta = jsonutils.loads(base64.b64decode(req['metadata'])) + except Exception: + raise exception.Forbidden('Invalid Request format') + + if not ('requestor' in meta): + raise exception.Forbidden('Invalid Request format') + + rkey = self._get_key(meta['requestor']) + + try: + signature = self.crypto.sign(rkey, req['metadata']) + except Exception: + raise exception.Unauthorized('Invalid Request') + + if signature != sig: + raise exception.Unauthorized('Invalid Request') + + timestamp = time.time() + + if meta['timestamp'] < (timestamp - self.ttl): + raise exception.Unauthorized('Invalid Request (expired)') + + #TODO(simo): check and store signature for replay attack detection + + tkey = self._get_key(meta['target']) + if not tkey: + raise exception.Unauthorized('Invalid Target') + + # use new_key to get a random salt + rndkey = self.hkdf.extract(rkey, self.crypto.new_key(KEY_SIZE)) + + info = '%s,%s,%s' % (meta['requestor'], meta['target'], str(timestamp)) + + sek = self.hkdf.expand(rndkey, info, KEY_SIZE * 2) + skey, ekey = self._split_key(sek, KEY_SIZE) + keydata = {'key': base64.b64encode(rndkey), + 'timestamp': timestamp, 'ttl': self.ttl} + esek = self.crypto.encrypt(tkey, jsonutils.dumps(keydata)) + ticket = jsonutils.dumps({'skey': base64.b64encode(skey), + 'ekey': base64.b64encode(ekey), + 'esek': esek}) + + rep = dict() + metadata = jsonutils.dumps({'source': meta['requestor'], + 'destination': meta['target'], + 'expiration': (keydata['timestamp'] + + keydata['ttl']), + 'encryption': True}) + rep['metadata'] = base64.b64encode(metadata) + rep['ticket'] = self.crypto.encrypt(rkey, ticket) + rep['signature'] = self.crypto.sign(rkey, + (rep['metadata'] + rep['ticket'])) + + return {'reply': rep} + + def set_key(self, name, req): + self._set_key(name, base64.b64decode(req['key'])) + + +class Driver(object): + """Interface description for a KDS driver.""" + + def set_shared_key(self, kds_id, key): + """Set key related to kds_id.""" + raise exception.NotImplemented() + + def get_shared_key(self, kds_id): + """Get key related to kds_id. + + :returns: key + :raises: keystone.exception.ServiceNotFound + """ + raise exception.NotImplemented() diff --git a/keystone/contrib/kds/migrate_repo/__init__.py b/keystone/contrib/kds/migrate_repo/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone/contrib/kds/migrate_repo/__init__.py diff --git a/keystone/contrib/kds/migrate_repo/migrate.cfg b/keystone/contrib/kds/migrate_repo/migrate.cfg new file mode 100644 index 00000000..016cd278 --- /dev/null +++ b/keystone/contrib/kds/migrate_repo/migrate.cfg @@ -0,0 +1,20 @@ +[db_settings] +# Used to identify which repository this database is versioned under. +# You can use the name of your project. +repository_id=kds + +# The name of the database table used to track the schema version. +# This name shouldn't already be used by your project. +# If this is changed once a database is under version control, you'll need to +# change the table name in each database too. +version_table=migrate_version + +# When committing a change script, Migrate will attempt to generate the +# sql for all supported databases; normally, if one of them fails - probably +# because you don't have that database installed - it is ignored and the +# commit continues, perhaps ending successfully. +# Databases in this list MUST compile successfully during a commit, or the +# entire commit will fail. List the databases your application will actually +# be using to ensure your updates to that database work properly. +# This must be a list; example: ['postgres','sqlite'] +required_dbs=[] diff --git a/keystone/contrib/kds/migrate_repo/versions/001_kds_table.py b/keystone/contrib/kds/migrate_repo/versions/001_kds_table.py new file mode 100644 index 00000000..10e6e604 --- /dev/null +++ b/keystone/contrib/kds/migrate_repo/versions/001_kds_table.py @@ -0,0 +1,37 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Red Hat, Inc +# +# 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. + +import sqlalchemy as sql + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + kds_table = sql.Table('kds_keys', meta, + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('name', sql.Text()), + sql.Column('key', sql.Text()), + sql.Column('extra', sql.Text())) + kds_table.create(migrate_engine, checkfirst=True) + + +def downgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + table = sql.Table('kds_keys', meta, autoload=True) + table.drop(migrate_engine, checkfirst=True) diff --git a/keystone/contrib/kds/migrate_repo/versions/__init__.py b/keystone/contrib/kds/migrate_repo/versions/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone/contrib/kds/migrate_repo/versions/__init__.py diff --git a/keystone/contrib/kds/routers.py b/keystone/contrib/kds/routers.py new file mode 100644 index 00000000..49b77796 --- /dev/null +++ b/keystone/contrib/kds/routers.py @@ -0,0 +1,39 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Red Hat, Inc. +# +# 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. + +from keystone.common import wsgi +from keystone.contrib.kds import controllers + + +class KDSExtension(wsgi.ExtensionRouter): + def add_routes(self, mapper): + kds_controller = controllers.KDSController() + + # crud + mapper.connect('/kds', + controller=kds_controller, + action='get_info', + conditions=dict(method=['GET'])) + + mapper.connect('/kds/ticket/{signature}', + controller=kds_controller, + action='get_ticket', + conditions=dict(method=['POST'])) + + mapper.connect('/kds/key/{name}', + controller=kds_controller, + action='set_key', + conditions=dict(method=['PUT'])) diff --git a/keystone/service.py b/keystone/service.py index e3633865..45ba501e 100644 --- a/keystone/service.py +++ b/keystone/service.py @@ -25,6 +25,7 @@ from keystone.common import wsgi from keystone import config from keystone.contrib import ec2 from keystone.contrib import oauth1 +from keystone.contrib import kds from keystone import controllers from keystone import credential from keystone import identity @@ -49,6 +50,7 @@ DRIVERS = dict( catalog_api=catalog.Manager(), credentials_api=credential.Manager(), ec2_api=ec2.Manager(), + kds_api=kds.Manager(), identity_api=_IDENTITY_API, oauth1_api=oauth1.Manager(), policy_api=policy.Manager(), diff --git a/keystone/tests/core.py b/keystone/tests/core.py index cba6cbf8..336d2eb5 100644 --- a/keystone/tests/core.py +++ b/keystone/tests/core.py @@ -222,6 +222,7 @@ class TestCase(NoModule, unittest.TestCase): testsdir('test_overrides.conf')]) self.mox = mox.Mox() self.opt(policy_file=etcdir('policy.json')) + self.opt_in_group('kds', master_key_file=tmpdir('kds.mkey')) self.stubs = stubout.StubOutForTesting() self.stubs.Set(exception, '_FATAL_EXCEPTION_FORMAT_ERRORS', True) diff --git a/keystone/tests/test_sql_migrate_extensions.py b/keystone/tests/test_sql_migrate_extensions.py index f9393cbe..cd4ffec3 100644 --- a/keystone/tests/test_sql_migrate_extensions.py +++ b/keystone/tests/test_sql_migrate_extensions.py @@ -28,6 +28,7 @@ To run these tests against a live database: from keystone.contrib import example from keystone.contrib import oauth1 +from keystone.contrib import kds import test_sql_upgrade @@ -108,3 +109,19 @@ class SqlUpgradeOAuth1Extension(test_sql_upgrade.SqlMigrateBase): self.assertTableDoesNotExist('consumer') self.assertTableDoesNotExist('request_token') self.assertTableDoesNotExist('access_token') + + +class SqlUpgradeKdsExtension(test_sql_upgrade.SqlMigrateBase): + def repo_package(self): + return kds + + def test_upgrade(self): + self.assertTableDoesNotExist('kds_keys') + self.upgrade(1, repository=self.repo_path) + self.assertTableColumns('kds_keys', ['id', 'name', 'key', 'extra']) + + def test_downgrade(self): + self.upgrade(1, repository=self.repo_path) + self.assertTableColumns('kds_keys', ['id', 'name', 'key', 'extra']) + self.downgrade(0, repository=self.repo_path) + self.assertTableDoesNotExist('kds_keys') diff --git a/requirements.txt b/requirements.txt index 7b6190d8..e767b7e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ python-keystoneclient>=0.3.0 oslo.config>=1.1.0 Babel>=0.9.6 oauth2 +pycrypto>=2.6 |