Add ticket handling to KDS

Adds creation of tickets to transmit communication keys between peers.

SecurityImpact
Change-Id: I4dbd23adb0bdd9011eb9a0b45e30dd862d390473
This commit is contained in:
Jamie Lennox 2013-12-03 12:33:28 +10:00
parent 33c5b9f2e3
commit 6b54c09bad
11 changed files with 555 additions and 13 deletions

View File

@ -13,6 +13,7 @@
import pecan
from kite.api.v1.controllers import key as key_controller
from kite.api.v1.controllers import ticket as ticket_controller
class Controller(object):
@ -27,6 +28,7 @@ class Controller(object):
'rel': 'self'}]}
keys = key_controller.KeyController()
tickets = ticket_controller.TicketController()
@pecan.expose('json')
def index(self):

View File

@ -0,0 +1,52 @@
# 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 pecan
from pecan import rest
import wsme
import wsmeext.pecan as wsme_pecan
from kite.api.v1 import models
from kite.openstack.common import jsonutils
class TicketController(rest.RestController):
@wsme.validate(models.Ticket)
@wsme_pecan.wsexpose(models.Ticket, body=models.TicketRequest)
def post(self, ticket_request):
# verify all required fields present and the signature is correct
ticket_request.verify()
# create a new random base key. With the combination of this base key
# and the information available in the metadata a client will be able
# to re-generate the keys required for this session.
rndkey = pecan.request.crypto.extract(ticket_request.source.key,
pecan.request.crypto.new_key())
# generate the keys to communicate between these two endpoints.
s_key, e_key = pecan.request.crypto.generate_keys(rndkey,
ticket_request.info)
# encrypt the base key for the target, this can be used to generate
# the sek on the target
esek_data = {'key': base64.b64encode(rndkey),
'timestamp': ticket_request.time_str,
'ttl': ticket_request.ttl.seconds}
# encrypt returns a base64 encrypted string
esek = pecan.request.crypto.encrypt(ticket_request.destination.key,
jsonutils.dumps(esek_data))
return ticket_request.new_response(e_key, s_key, esek)

View File

@ -11,8 +11,11 @@
# under the License.
from kite.api.v1.models import key
from kite.api.v1.models import ticket
KeyInput = key.KeyInput
KeyData = key.KeyData
Ticket = ticket.Ticket
TicketRequest = ticket.TicketRequest
__all__ = [KeyInput, KeyData]
__all__ = [KeyInput, KeyData, Ticket, TicketRequest]

171
kite/api/v1/models/base.py Normal file
View File

@ -0,0 +1,171 @@
# 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 functools
import pecan
import wsme
from kite.common import exception
from kite.common import utils
from kite.openstack.common import jsonutils
from kite.openstack.common import timeutils
def memoize(f):
"""Create a property and cache the return value for future."""
@property
@functools.wraps(f)
def wrapper(self):
try:
val = self._cache[f.func_name]
except KeyError:
val = f(self)
self._cache[f.func_name] = val
return val
return wrapper
def malformed(msg):
"""Raise a malformed message exception if something goes wrong."""
def wrap(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
try:
return f(*args, **kwargs)
except Exception:
pecan.abort(400, 'Invalid %s' % msg)
return wrapper
return wrap
class Endpoint(object):
"""A source or destination for a ticket."""
def __init__(self, endpoint_str):
self._cache = dict()
self._set_endpoint(endpoint_str)
@malformed('endpoint')
def _set_endpoint(self, endpoint_str):
self.host, self.generation = utils.split_host(endpoint_str)
@memoize
def key_data(self):
try:
return pecan.request.storage.get_key(self.host, self.generation)
except exception.CryptoError:
pecan.abort(500, "Failed to decrypt key for '%s:%s'. " %
(self.host, self.generation))
except exception.KeyNotFound:
pecan.abort(404, "Could not find key")
@property
def key(self):
return self.key_data['key']
@property
def key_generation(self):
return self.key_data['generation']
@property
def key_str(self):
return utils.join_host(self.host, self.key_generation)
class BaseRequest(wsme.types.Base):
metadata = wsme.wsattr(wsme.types.text, mandatory=True)
signature = wsme.wsattr(wsme.types.text, mandatory=True)
def __init__(self, **kwargs):
super(BaseRequest, self).__init__(**kwargs)
self._cache = dict()
self.now = timeutils.utcnow()
@memoize
@malformed("metadata")
def meta(self):
return jsonutils.loads(base64.decodestring(self.metadata))
@memoize
@malformed("source")
def source(self):
return Endpoint(self.meta['source'])
@memoize
@malformed("destination")
def destination(self):
return Endpoint(self.meta['destination'])
@memoize
@malformed("timestamp")
def timestamp(self):
return timeutils.parse_strtime(self.meta['timestamp'])
@property
@malformed("nonce")
def nonce(self):
return self.meta['nonce']
@property
def time_str(self):
return timeutils.strtime(self.now)
def verify(self):
"""Ensure that the ticket request is recent enough to be valid and
the signature is correct for the requestor.
"""
if (self.now - self.timestamp) > self.ttl:
pecan.abort(401, 'Ticket validity expired')
if not self.nonce:
# just check this until we actually use it
pecan.abort(400, 'Invalid nonce')
try:
sigc = pecan.request.crypto.sign(self.source.key, self.metadata)
except exception.CryptoError:
pecan.abort(400, "Unexpected error: Couldn't reproduce signature")
if sigc != self.signature:
pecan.abort(401, 'Invalid Signature')
class BaseResponse(wsme.types.Base):
metadata = wsme.wsattr(wsme.types.text, mandatory=True)
signature = wsme.wsattr(wsme.types.text, mandatory=True)
def set_metadata(self, source, destination, expiration):
"""Attach the generation metadata to the ticket.
This informs the client and server of expiration and the expect sending
and receiving host and will be validated by both client and server.
"""
metadata = jsonutils.dumps({'source': source,
'destination': destination,
'expiration': expiration,
'encryption': True})
self.metadata = base64.b64encode(metadata)
def sign(self, key, data):
"""Sign the response.
This will be signed with the requestor's key so that it knows that the
issuing server has a correct copy of the key.
"""
self.signature = pecan.request.crypto.sign(key, self.metadata + data)

View File

@ -0,0 +1,81 @@
# 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 datetime
import pecan
import wsme
from kite.api.v1.models import base
from kite.openstack.common import jsonutils
class Ticket(base.BaseResponse):
ticket = wsme.wsattr(wsme.types.text, mandatory=True)
def set_ticket(self, rkey, enc_key, signature, esek):
"""Create and encrypt a ticket to the requestor.
The requestor will be able to decrypt the ticket with their key and the
information in the metadata to get the new point-to-point key.
"""
ticket = jsonutils.dumps({'skey': base64.b64encode(signature),
'ekey': base64.b64encode(enc_key),
'esek': esek})
self.ticket = pecan.request.crypto.encrypt(rkey, ticket)
def sign(self, key):
"""Sign the ticket response.
This will be signed with the requestor's key so that it knows that the
issuing server has a correct copy of the key.
"""
super(Ticket, self).sign(key, self.ticket)
class TicketRequest(base.BaseRequest):
def __init__(self, **kwargs):
super(TicketRequest, self).__init__(**kwargs)
seconds = int(pecan.request.conf.ticket_lifetime)
self.ttl = datetime.timedelta(seconds=seconds)
@property
def info(self):
"""A predictable text string that can be used as the base for
generating keys.
"""
return "%s,%s,%s" % (self.source.key_str,
self.destination.key_str,
self.time_str)
def new_response(self, enc_key, signature, esek):
response = Ticket()
response.set_metadata(source=self.source.key_str,
destination=self.destination.key_str,
expiration=self.now + self.ttl)
# encrypt the sig and key back to the requester as well as the esek
# to forward with messages.
response.set_ticket(self.source.key, enc_key, signature, esek)
# finish building response and sign it, we sign it with the requester's
# key at the end because the ticket doesn't have to be encrypted and we
# still have to provide integrity of the ticket.
response.sign(self.source.key)
return response

View File

@ -12,6 +12,7 @@
import base64
import errno
import logging
import os
from oslo.config import cfg
@ -41,6 +42,8 @@ CONF.register_group(cfg.OptGroup(name='crypto',
title='Cryptographic Options'))
CONF.register_opts(CRYPTO_OPTS, group='crypto')
_logger = logging.getLogger(__name__)
class CryptoManager(utils.SingletonManager):
@ -73,6 +76,10 @@ class CryptoManager(utils.SingletonManager):
try:
f = os.open(CONF.crypto.master_key_file, flags, 0o600)
os.write(f, base64.b64encode(mkey))
except Exception as x:
_logger.warn('Failed to read master key initially: %s', e)
_logger.warn('Failed to create new master key: %s', x)
raise x
finally:
if f:
os.close(f)
@ -83,7 +90,10 @@ class CryptoManager(utils.SingletonManager):
return mkey
def generate_keys(self, prk, info, key_size):
def new_key(self, key_size=KEY_SIZE):
return self.crypto.new_key(key_size)
def generate_keys(self, prk, info, key_size=KEY_SIZE):
"""Generate a new key from an existing key and information.
:param string prk: Existing pseudo-random key
@ -94,6 +104,15 @@ class CryptoManager(utils.SingletonManager):
key = self.hkdf.expand(prk, info, 2 * key_size)
return key[:key_size], key[key_size:]
def extract(self, key, rnd_data):
return self.hkdf.extract(key, rnd_data)
def encrypt(self, key, data):
return self.crypto.encrypt(key, data)
def sign(self, key, data):
return self.crypto.sign(key, data)
def get_storage_keys(self, name):
"""Get a set of keys that will be used to encrypt the data for this
identity in the database.

View File

@ -73,3 +73,21 @@ class SingletonManager(object):
@classmethod
def reset(cls):
cls._instance = None
def split_host(string):
if not string:
return (None, None)
try:
host, generation = string.rsplit(':', 1)
generation = int(generation)
except ValueError:
host = string
generation = None
return (host, generation)
def join_host(host, generation):
return "%s:%d" % (host, generation)

View File

@ -14,6 +14,7 @@ import webtest
import pecan.testing
from kite.common import crypto
from kite.common import storage
from kite.db import api as db_api
from kite.openstack.common import jsonutils
@ -56,13 +57,14 @@ class BaseTestCase(base.BaseTestCase):
},
}
# self.useFixture(fixture.SqliteDb())
self.CRYPTO = crypto.CryptoManager.get_instance()
self.DB = db_api.get_instance()
self.STORAGE = storage.StorageManager.get_instance()
self.app = pecan.testing.load_test_app(self.app_config)
self.addCleanup(pecan.set_config, {}, overwrite=True)
def request(self, url, method, **kwargs):
def request(self, url, method, expected_status=None, **kwargs):
try:
json = kwargs.pop('json')
except KeyError:

View File

@ -0,0 +1,181 @@
# 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 datetime
import six
from kite.openstack.common.crypto import utils as cryptoutils
from kite.openstack.common import jsonutils
from kite.openstack.common import timeutils
from kite.tests.api.v1 import base
SOURCE_KEY = base64.b64decode('LDIVKc+m4uFdrzMoxIhQOQ==')
DEST_KEY = base64.b64decode('EEGfTxGFcZiT7oPO+brs+A==')
TEST_KEY = base64.b64decode('Jx5CVBcxuA86050355mTrg==')
DEFAULT_SOURCE = 'home.local'
DEFAULT_DEST = 'tests.openstack.remote'
DEFAULT_GROUP = 'home'
DEFAULT_NONCE = '42'
class TicketTest(base.BaseTestCase):
def setUp(self):
super(TicketTest, self).setUp()
self.crypto = cryptoutils.SymmetricCrypto(
enctype=self.CONF.crypto.enctype,
hashtype=self.CONF.crypto.hashtype)
def _ticket_metadata(self, source=DEFAULT_SOURCE,
destination=DEFAULT_DEST, nonce=DEFAULT_NONCE,
timestamp=None, b64encode=True):
if not timestamp:
timestamp = timeutils.utcnow()
return {'source': source, 'destination': destination,
'nonce': nonce, 'timestamp': timestamp}
def _add_key(self, name, key=None, b64encode=True):
if not key:
if name == DEFAULT_SOURCE:
key = SOURCE_KEY
elif name == DEFAULT_DEST:
key = DEST_KEY
else:
raise ValueError("No default key available")
if b64encode:
key = base64.b64encode(key)
resp = self.put('keys/%s' % name,
status=200,
json={'key': key}).json
return "%s:%s" % (resp['name'], resp['generation'])
def _request_ticket(self, metadata=None, signature=None,
source=DEFAULT_SOURCE, destination=DEFAULT_DEST,
nonce=DEFAULT_NONCE, timestamp=None,
source_key=None, status=200):
if not metadata:
metadata = self._ticket_metadata(source=source,
nonce=nonce,
destination=destination,
timestamp=timestamp)
if not isinstance(metadata, six.text_type):
metadata = base64.b64encode(jsonutils.dumps(metadata))
if not signature:
if not source_key and source == DEFAULT_SOURCE:
source_key = SOURCE_KEY
signature = self.crypto.sign(source_key, metadata)
return self.post('tickets',
json={'metadata': metadata, 'signature': signature},
status=status)
def test_valid_ticket(self):
self._add_key(DEFAULT_SOURCE)
self._add_key(DEFAULT_DEST)
response = self._request_ticket().json
b64m = response['metadata']
metadata = jsonutils.loads(base64.b64decode(b64m))
signature = response['signature']
b64t = response['ticket']
# check signature was signed to source
csig = self.crypto.sign(SOURCE_KEY, b64m + b64t)
self.assertEqual(signature, csig)
# decrypt the ticket base if required, done by source
if metadata['encryption']:
ticket = self.crypto.decrypt(SOURCE_KEY, b64t)
ticket = jsonutils.loads(ticket)
skey = base64.b64decode(ticket['skey'])
ekey = base64.b64decode(ticket['ekey'])
b64esek = ticket['esek']
# the esek part is sent to the destination, so destination should be
# able to decrypt it from here.
esek = self.crypto.decrypt(DEST_KEY, b64esek)
esek = jsonutils.loads(esek)
self.assertEqual(int(self.CONF.ticket_lifetime), esek['ttl'])
# now should be able to reconstruct skey, ekey from esek data
info = '%s,%s,%s' % (metadata['source'], metadata['destination'],
esek['timestamp'])
key = base64.b64decode(esek['key'])
new_sig, new_key = self.CRYPTO.generate_keys(key, info)
self.assertEqual(new_key, ekey)
self.assertEqual(new_sig, skey)
def test_missing_source_key(self):
self._add_key(DEFAULT_DEST)
self._request_ticket(status=404)
def test_missing_dest_key(self):
self._add_key(DEFAULT_SOURCE)
self._request_ticket(status=404)
def test_wrong_source_key(self):
# install TEST_KEY but sign with SOURCE_KEY
self._add_key(DEFAULT_SOURCE, TEST_KEY)
self._add_key(DEFAULT_DEST)
self._request_ticket(status=401)
def test_invalid_signature(self):
self._add_key(DEFAULT_SOURCE)
self._add_key(DEFAULT_DEST)
self._request_ticket(status=401, signature='bad-signature')
def test_invalid_expired_request(self):
self._add_key(DEFAULT_SOURCE)
self._add_key(DEFAULT_DEST)
timestamp = timeutils.utcnow() - datetime.timedelta(hours=5)
self._request_ticket(status=401, timestamp=timestamp)
def test_fails_on_garbage_metadata(self):
self._request_ticket(metadata='garbage',
signature='signature',
status=400)
self._request_ticket(metadata='{"json": "string"}',
signature='signature',
status=400)
def test_missing_attributes_in_metadata(self):
self._add_key(DEFAULT_SOURCE)
self._add_key(DEFAULT_DEST)
for attr in ['source', 'timestamp', 'destination', 'nonce']:
metadata = self._ticket_metadata(b64encode=False)
del metadata[attr]
self._request_ticket(metadata=metadata, status=400)

View File

@ -10,6 +10,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import os
from oslo.config import cfg
from oslotest import base
@ -17,7 +19,7 @@ from kite.common import crypto
from kite.common import service
from kite.common import storage
from kite.openstack.common.fixture import config
from kite.tests import paths
from kite.openstack.common.fixture import mockpatch
CONF = cfg.CONF
CONF.import_opt('master_key_file', 'kite.common.crypto', group='crypto')
@ -33,12 +35,13 @@ class BaseTestCase(base.BaseTestCase):
storage.StorageManager.reset()
crypto.CryptoManager.reset()
service.parse_args(args=[])
self.mkey = os.urandom(crypto.CryptoManager.KEY_SIZE)
patch = mockpatch.Patch(
'kite.common.crypto.CryptoManager._load_master_key',
new=lambda x: self.mkey)
self.useFixture(patch)
self.master_key_file = paths.tmp_path('mkey.key')
self.config(group='crypto',
master_key_file=self.master_key_file,
)
service.parse_args(args=[])
def config(self, *args, **kwargs):
self.config_fixture.config(*args, **kwargs)

View File

@ -12,9 +12,13 @@
import base64
import os
import uuid
from oslotest import base as oslo_base
from kite.common import crypto
from kite.common import exception
from kite.openstack.common.fixture import config
from kite.tests import paths
from kite.tests.unit import base
@ -74,7 +78,13 @@ class CryptoTests(base.BaseTestCase):
anot_name, enc_key, sig)
class CryptoMasterKeyTests(base.BaseTestCase):
class CryptoMasterKeyTests(oslo_base.BaseTestCase):
def setUp(self):
super(CryptoMasterKeyTests, self).setUp()
self.config_fixture = self.useFixture(config.Config())
self.CONF = self.config_fixture.conf
def _remove_file(self, f):
try:
@ -83,11 +93,11 @@ class CryptoMasterKeyTests(base.BaseTestCase):
pass
def test_key_creation(self):
keyfile = paths.test_path('test-kds.mkey')
keyfile = paths.test_path('%s.mkey' % uuid.uuid4().hex)
self._remove_file(keyfile)
self.addCleanup(self._remove_file, keyfile)
self.config(group='crypto', master_key_file=keyfile)
self.config_fixture.config(group='crypto', master_key_file=keyfile)
CRYPTO = crypto.CryptoManager()
self.assertTrue(os.path.exists(keyfile))