summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--etc/keystone-paste.ini5
-rw-r--r--etc/keystone.conf.sample7
-rw-r--r--keystone/contrib/kds/__init__.py19
-rw-r--r--keystone/contrib/kds/backends/__init__.py0
-rw-r--r--keystone/contrib/kds/backends/sql.py68
-rw-r--r--keystone/contrib/kds/controllers.py31
-rw-r--r--keystone/contrib/kds/core.py234
-rw-r--r--keystone/contrib/kds/migrate_repo/__init__.py0
-rw-r--r--keystone/contrib/kds/migrate_repo/migrate.cfg20
-rw-r--r--keystone/contrib/kds/migrate_repo/versions/001_kds_table.py37
-rw-r--r--keystone/contrib/kds/migrate_repo/versions/__init__.py0
-rw-r--r--keystone/contrib/kds/routers.py39
-rw-r--r--keystone/service.py2
-rw-r--r--keystone/tests/core.py1
-rw-r--r--keystone/tests/test_sql_migrate_extensions.py17
-rw-r--r--requirements.txt1
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