diff options
author | Simo Sorce <simo@redhat.com> | 2015-04-20 16:08:28 -0400 |
---|---|---|
committer | Simo Sorce <simo@redhat.com> | 2015-04-27 15:06:26 -0400 |
commit | a2ed51acfdff399a6ad6cd486eb22da9acf59280 (patch) | |
tree | 1b49d6338e2322450efe9da92c655012105cd3cd | |
parent | b9e31bf1cc44bdfeaf0454dadb578c4dbb8d588b (diff) | |
download | custodia-a2ed51acfdff399a6ad6cd486eb22da9acf59280.tar.gz custodia-a2ed51acfdff399a6ad6cd486eb22da9acf59280.tar.xz custodia-a2ed51acfdff399a6ad6cd486eb22da9acf59280.zip |
Add support for signed/encrypted messages
The new 'kem' type allows the backend to authorize access to keys based on
a signed request where the key mus be whitelisted in advance in a kemkeys
database.
The reply is encrypted with the client public key.
Signed-off-by: Simo Sorce <simo@redhat.com>
-rw-r--r-- | API.md | 59 | ||||
-rw-r--r-- | custodia.conf | 17 | ||||
-rw-r--r-- | custodia/message/formats.py | 6 | ||||
-rw-r--r-- | custodia/message/kem.py | 275 | ||||
-rw-r--r-- | examples/client_enc.key | 1 | ||||
-rw-r--r-- | examples/enclite.db | bin | 0 -> 7168 bytes | |||
-rw-r--r-- | examples/enclite.sample.key (renamed from enclite.sample.key) | 0 | ||||
-rw-r--r-- | tests/tests.py | 4 |
8 files changed, 355 insertions, 7 deletions
@@ -10,8 +10,8 @@ is to mount th secrets api under the /secrets URI The Custodia API uses JSON to format requests and replies. -Key format -========== +Key/Request formats +=================== A key is a dictionary that contains the 'type' and 'value' of a key. Currently only the Simple type is recognized @@ -31,6 +31,61 @@ validated before being stored, unknown key types or invalid JSON values are refused and an error is returned. +Key Exchange Message +-------------------- + +the Key Exchange Message format builds on the JSON Web Signature and the +JSON Web Encryption specification to build respectively the request and +reply messages. +The aim is to provide the Custodia server the means to encrypt the reply +to a client that proves possession of private key. + +Format: +- Query arguments for GET: + type=kem + value=Message +- JSON Value for PUT/GET Reply: + {"type:"kem","value":"Message"} + + The Message for a GET is a JWT (JWS): + (flattened/decoded here for clarity) + { "protected": { "kid": <public-key-dentifier>, + "alg": "a valid alg name"}, + "payload": { "name": <name-of-secret>, + "time": <unix-timestamp>, + ["value": <arbitrary> ]}, + "signature": "XYZ...." } + + Attributes: + - public-key-identifier: This is the kid of a key that must be known to the + Custodia service. If opportunistic encription is desired, and the requesting + client is authenticated in other ways a "jku" header could be used instead, + and a key fetched on the fly. This is not recommended for the gneral case and + is not currently supported by the implementation. + - name-of-secret: this repeates the name of the secret embedded in the GET, + This is used to prevent substitution attacks where a client is intercepted + and its signed request is reused to request a different key. + - unix-timestamp: used to limit replay attacks + Additional payload attributes may be present, for example a 'value'. + + The Message for a GET reply or a PUT is a JWS Encoded message (see above) + nested in a JWE Encoded message: + (flattened/decoded here for clarity): + { "protected": { "kid": <public-key-dentifier>, + "alg": "a valid alg name", + "enc": "a valid enc type"}, + "encrypted_key": <JWE_Encrypted_Key>, + "iv": <Initialization Vector>, + "ciphertext": <Encrypted Content>, + "tag": <Authentication Tag> } + + Attributes: + - public-key-identifier: Must be the server public key identifier. + reply (see above). Or the server public key for a PUT. + - The inner JWS payload will typically contain a 'value' that is + an arbitrary key. + example: { type: "simple", value: <arbitrary> } + REST API ======== diff --git a/custodia.conf b/custodia.conf index 013d7e2..1f9dff9 100644 --- a/custodia.conf +++ b/custodia.conf @@ -48,9 +48,9 @@ store = tenant1 # Encstore example [store:encrypted] handler = custodia.store.enclite.EncryptedStore -dburi = secrets.db +dburi = examples/enclite.db table = enclite -master_key = ./enclite.sample.key +master_key = examples/enclite.sample.key master_enctype = A128CBC-HS256 [authz:encrypted] @@ -58,6 +58,19 @@ handler = custodia.secrets.Namespaces path = /enc/secrets/ store = encrypted +[store:kemkeys] +handler = custodia.store.enclite.EncryptedStore +dburi = examples/enclite.db +table = enclite +master_key = examples/enclite.sample.key +master_enctype = A128CBC-HS256 + +[authz:kkstore] +handler = custodia.message.kem.KEMKeysStore +path = /enc/secrets/ +store = kemkeys + [/enc/secrets] handler = custodia.root.Secrets +allowed_keytypes = simple kem store = encrypted diff --git a/custodia/message/formats.py b/custodia/message/formats.py index f8ddc69..48d5955 100644 --- a/custodia/message/formats.py +++ b/custodia/message/formats.py @@ -4,11 +4,13 @@ from custodia.message.common import InvalidMessage from custodia.message.common import UnknownMessageType from custodia.message.common import UnallowedMessage from custodia.message.simple import SimpleKey +from custodia.message.kem import KEMHandler -default_types = ['simple'] +default_types = ['simple', 'kem'] -key_types = {'simple': SimpleKey} +key_types = {'simple': SimpleKey, + 'kem': KEMHandler} class Validator(object): diff --git a/custodia/message/kem.py b/custodia/message/kem.py new file mode 100644 index 0000000..74feacd --- /dev/null +++ b/custodia/message/kem.py @@ -0,0 +1,275 @@ +# Copyright (C) 2015 Custodia Project Contributors - see LICENSE file + +from custodia.httpd.authorizers import SimplePathAuthz +from custodia.message.common import InvalidMessage +from custodia.message.common import MessageHandler +from jwcrypto.common import json_decode +from jwcrypto.common import json_encode +from jwcrypto.jwe import JWE +from jwcrypto.jwk import JWK +from jwcrypto.jws import JWS +from jwcrypto.jwt import JWT +import os +import time + + +class UnknownPublicKey(Exception): + pass + + +class KEMKeysStore(SimplePathAuthz): + """A KEM Keys Store. + + This is a store that holds public keys of registered + clients allowed to use KEM messages. It takes the form + of an authorizer merely for the purpose of attaching + itself to a 'request' so that later on the KEM Parser + can fetch the appropariate key to verify/decrypt an + incoming request and make the payload available. + + The KEM Parser will actually pergorm additional + authorization checks in this case. + + SimplePathAuthz is extended here as we ant to attach the + store only to requests on paths we are configured to + manage. + """ + + def __init__(self, config=None): + super(KEMKeysStore, self).__init__(config) + self.paths = [] + if 'paths' in self.config: + self.paths = self.config['paths'].split() + self._server_key = None + self._alg = None + self._enc = None + + def _db_key(self, kid): + return os.path.join('kemkeys', kid) + + def handle(self, request): + inpath = super(KEMKeysStore, self).handle(request) + if inpath: + request['KEMKeysStore'] = self + return inpath + + def find_key(self, kid): + dbkey = self._db_key(kid) + pubkey = self.store.get(dbkey) + if pubkey is None: + raise UnknownPublicKey(kid) + return pubkey + + @property + def server_key(self): + if self._server_key is None: + if 'server_key' not in self.config: + raise UnknownPublicKey("Server Key not defined") + key = self.find_key(self.config['server_key']) + self._server_key = JWK(**(json_decode(key))) + return self._server_key + + @property + def alg(self): + if self._alg is None: + alg = self.config.get('signing_algorithm', None) + if alg is None: + raise ValueError('Signing algorithm not configured') + self._alg = alg + return self._alg + + +class KEMHandler(MessageHandler): + """Handles 'kem' messages""" + + def __init__(self, request): + super(KEMHandler, self).__init__(request) + self.kkstore = self.req.get('KEMKeysStore', None) + if self.kkstore is None: + raise Exception('KEM KeyStore not configured') + self.client_key = None + self.name = None + + def _get_key(self, header): + if 'kid' not in header: + raise InvalidMessage("Missing key identifier") + + key = self.kkstore.find_key(header['kid']) + if key is None: + raise UnknownPublicKey('Key found [kid:%s]' % header['kid']) + return json_decode(key) + + def parse(self, msg): + """Parses the message. + + We check that the message is properly formatted. + + :param msg: a json-encoded value containing a JWS or JWE+JWS token + + :raises InvalidMessage: if the message cannot be parsed or validated + + :returns: A verified payload + """ + + try: + jtok = JWT(jwt=msg) + except Exception as e: + raise InvalidMessage('Failed to parse message: %s' % repr(e)) + + try: + token = jtok.token + if isinstance(token, JWS): + key = self._get_key(token.jose_header) + self.client_key = JWK(**key) # pylint: disable=star-args + token.verify(self.client_key) + payload = token.payload + elif isinstance(token, JWE): + token.decrypt(self.kkstore.server_key) + # If an ecnrypted payload is received then there must be + # a nestd signed payload to verify the provenance. + nested = JWS() + nested.deserialize(token.payload) + key = self._get_key(nested.jose_header) + self.client_key = JWK(**key) # pylint: disable=star-args + nested.verify(self.client_key) + payload = nested.payload + else: + raise TypeError("Invalid Token type: %s" % type(jtok)) + except Exception as e: + raise InvalidMessage('Failed to validate message: %s' % repr(e)) + + # FIXME: check name/time + + return {'type': 'kem', + 'value': {'kid': self.client_key.key_id, + 'payload': payload}} + + def reply(self, output): + if self.client_key is None: + raise UnknownPublicKey("Peer key not defined") + + ktype = self.client_key.key_type + if ktype == 'RSA': + enc = ('RSA1_5', 'A256CBC-HS512') + else: + raise ValueError("'%s' type not supported yet" % ktype) + + value = make_enc_kem(self.name, output, + self.kkstore.server_key, + self.kkstore.alg, + self.client_key, enc) + + return json_encode({'type': 'kem', 'value': value}) + + +def make_sig_kem(name, value, key, alg): + payload = {'name': name, 'time': int(time.time())} + if value is not None: + payload['value'] = value + S = JWS(json_encode(payload)) + prot = {'kid': key.key_id, 'alg': alg} + S.add_signature(key, protected=json_encode(prot)) + return S.serialize(compact=True) + + +def make_enc_kem(name, value, sig_key, alg, enc_key, enc): + plaintext = make_sig_kem(name, value, sig_key, alg) + eprot = {'kid': enc_key.key_id, 'alg': enc[0], 'enc': enc[1]} + E = JWE(plaintext, json_encode(eprot)) + E.add_recipient(enc_key) + return E.serialize(compact=True) + + +# unit tests +import unittest +from custodia.store.sqlite import SqliteStore + + +server_key = { + "kty": "RSA", + "kid": "65d64463-7448-499e-8acc-55db2ce67039", + "n": "maxhbsmBtdQ3CNrKvprUE6n9lYcregDMLYNeTAWcLj8NnPU9XIYegT" + "HVHQjxKDSHP2l-F5jS7sppG1wgdAqZyhnWvXhYNvcM7RfgKxqNx_xAHx" + "6f3yy7s-M9PSNCwPC2lh6UAkR4I00EhV9lrypM9Pi4lBUop9t5fS9W5U" + "NwaAllhrd-osQGPjIeI1deHTwx-ZTHu3C60Pu_LJIl6hKn9wbwaUmA4c" + "R5Bd2pgbaY7ASgsjCUbtYJaNIHSoHXprUdJZKUMAzV0WOKPfA6OPI4oy" + "pBadjvMZ4ZAj3BnXaSYsEZhaueTXvZB4eZOAjIyh2e_VOIKVMsnDrJYA" + "VotGlvMQ", + "e": "AQAB", + "d": "Kn9tgoHfiTVi8uPu5b9TnwyHwG5dK6RE0uFdlpCGnJN7ZEi963R7wy" + "bQ1PLAHmpIbNTztfrheoAniRV1NCIqXaW_qS461xiDTp4ntEPnqcKsyO" + "5jMAji7-CL8vhpYYowNFvIesgMoVaPRYMYT9TW63hNM0aWs7USZ_hLg6" + "Oe1mY0vHTI3FucjSM86Nff4oIENt43r2fspgEPGRrdE6fpLc9Oaq-qeP" + "1GFULimrRdndm-P8q8kvN3KHlNAtEgrQAgTTgz80S-3VD0FgWfgnb1PN" + "miuPUxO8OpI9KDIfu_acc6fg14nsNaJqXe6RESvhGPH2afjHqSy_Fd2v" + "pzj85bQQ", + "p": "2DwQmZ43FoTnQ8IkUj3BmKRf5Eh2mizZA5xEJ2MinUE3sdTYKSLtaE" + "oekX9vbBZuWxHdVhM6UnKCJ_2iNk8Z0ayLYHL0_G21aXf9-unynEpUsH" + "7HHTklLpYAzOOx1ZgVljoxAdWNn3hiEFrjZLZGS7lOH-a3QQlDDQoJOJ" + "2VFmU", + "q": "te8LY4-W7IyaqH1ExujjMqkTAlTeRbv0VLQnfLY2xINnrWdwiQ93_V" + "F099aP1ESeLja2nw-6iKIe-qT7mtCPozKfVtUYfz5HrJ_XY2kfexJINb" + "9lhZHMv5p1skZpeIS-GPHCC6gRlKo1q-idn_qxyusfWv7WAxlSVfQfk8" + "d6Et0", + "dp": "UfYKcL_or492vVc0PzwLSplbg4L3-Z5wL48mwiswbpzOyIgd2xHTH" + "QmjJpFAIZ8q-zf9RmgJXkDrFs9rkdxPtAsL1WYdeCT5c125Fkdg317JV" + "RDo1inX7x2Kdh8ERCreW8_4zXItuTl_KiXZNU5lvMQjWbIw2eTx1lpsf" + "lo0rYU", + "dq": "iEgcO-QfpepdH8FWd7mUFyrXdnOkXJBCogChY6YKuIHGc_p8Le9Mb" + "pFKESzEaLlN1Ehf3B6oGBl5Iz_ayUlZj2IoQZ82znoUrpa9fVYNot87A" + "CfzIG7q9Mv7RiPAderZi03tkVXAdaBau_9vs5rS-7HMtxkVrxSUvJY14" + "TkXlHE", + "qi": "kC-lzZOqoFaZCr5l0tOVtREKoVqaAYhQiqIRGL-MzS4sCmRkxm5vZ" + "lXYx6RtE1n_AagjqajlkjieGlxTTThHD8Iga6foGBMaAr5uR1hGQpSc7" + "Gl7CF1DZkBJMTQN6EshYzZfxW08mIO8M6Rzuh0beL6fG9mkDcIyPrBXx" + "2bQ_mM"} + + +class KEMTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + config = { + 'server_key': server_key['kid'], + 'signing_algorithm': 'RS256', + 'encryption_algorithms': 'RSA1_5 A128CBC-HS256'} + with open('examples/client_enc.key') as f: + data = f.read() + cls.client_key = json_decode(data) + cls.kk = KEMKeysStore(config) + cls.kk.store = SqliteStore({'dburi': 'kemtests.db'}) + cls.kk.store.set(os.path.join('kemkeys', server_key['kid']), + json_encode(server_key), True) + cls.kk.store.set(os.path.join('kemkeys', cls.client_key['kid']), + json_encode(cls.client_key), True) + + @classmethod + def AtearDownClass(self): + try: + os.unlink('kemtests.db') + except OSError: + pass + + def make_tok(self, key, alg, name): + pri_key = JWK(**key) # pylint: disable=star-args + protected = {"typ": "JOSE+JSON", + "kid": key['kid'], + "alg": alg} + plaintext = {"name": name, + "time": int(time.time())} + S = JWS(payload=json_encode(plaintext)) + S.add_signature(pri_key, None, json_encode(protected)) + return S.serialize() + + def test_1_Parse_GET(self): + cli_key = JWK(**self.client_key) # pylint: disable=star-args + jtok = make_sig_kem("mykey", None, cli_key, "RS256") + kem = KEMHandler({'KEMKeysStore': self.kk}) + kem.parse(jtok) + out = kem.reply('output') + jtok = JWT(jwt=json_decode(out)['value']) + jtok.token.decrypt(cli_key) + nested = jtok.token.payload + jtok = JWT(jwt=nested) + jtok.token.verify(JWK(**server_key)) # pylint: disable=star-args + payload = json_decode(jtok.token.payload)['value'] + self.assertEqual(payload, 'output') diff --git a/examples/client_enc.key b/examples/client_enc.key new file mode 100644 index 0000000..1def71b --- /dev/null +++ b/examples/client_enc.key @@ -0,0 +1 @@ +{"p":"2rnSOV4hKSN8sS4CgcQHFbs08XboFDqKum3sc4h3GRxrTmQdl1ZK9uw-PIHfQP0FkxXVrx-WE-ZEbrqivH_2iCLUS7wAl6XvARt1KkIaUxPPSYB9yk31s0Q8UK96E3_OrADAYtAJs-M3JxCLfNgqh56HDnETTQhH3rCT5T3yJws","kid":"984f6264-ce8e-407b-9e44-f9c4aaee3f71","dq":"AvfS0-gRxvn0bwJoMSnFxYcK1WnuEjQFluMGfwGitQBWtfZ1Er7t1xDkbN9GQTB9yqpDoYaN06H7CFtrkxhJIBQaj6nkF5KKS3TQtQ5qCzkOkmxIe3KRbBymXxkb5qwUpX5ELD5xFc6FeiafWYY63TmmEAu_lRFCOJ3xDea-ots","qi":"lSQi-w9CpyUReMErP1RsBLk7wNtOvs5EQpPqmuMvqW57NBUczScEoPwmUqqabu9V0-Py4dQ57_bapoKRu1R90bvuFnU63SHWEFglZQvJDMeAvmj4sm-Fp0oYu_neotgQ0hzbI5gry7ajdYy9-2lNx_76aBZoOUu9HCJ-UsfSOI8","q":"1u_RiFDP7LBYh3N4GXLT9OpSKYP0uQZyiaZwBtOCBNJgQxaj10RWjsZu0c6Iedis4S7B_coSKB0Kj9PaPaBzg-IySRvvcQuPamQu66riMhjVtG6TlV8CLCYKrYl52ziqK0E_ym2QnkwsUX7eYTB7LbAHRK9GqocDE5B0f808I4s","e":"AQAB","dp":"KkMTWqBUefVwZ2_Dbj1pPQqyHSHjj90L5x_MOzqYAJMcLMZtbUtwKqvVDq3tbEo3ZIcohbDtt6SbfmWzggabpQxNxuBpoOOf_a_HgMXK_lhqigI4y_kqS1wY52IwjUn5rgRrJ-yYo1h41KR-vz2pYhEAeYrhttWtxVqLCRViD6c","n":"t6Q8PWSi1dkJj9hTP8hNYFlvadM7DflW9mWepOJhJ66w7nyoK1gPNqFMSQRyO125Gp-TEkodhWr0iujjHVx7BcV0llS4w5ACGgPrcAd6ZcSR0-Iqom-QFcNP8Sjg086MwoqQU_LYywlAGZ21WSdS_PERyGFiNnj3QQlO8Yns5jCtLCRwLHL0Pb1fEv45AuRIuUfVcPySBWYnDyGxvjYGDSM-AqWS9zIQ2ZilgT-GqUmipg0XOC0Cc20rgLe2ymLHjpHciCKVAbY5-L32-lSeZO-Os6U15_aXrk9Gw8cPUaX1_I8sLGuSiVdt3C_Fn2PZ3Z8i744FPFGGcG1qs2Wz-Q","d":"GRtbIQmhOZtyszfgKdg4u_N-R_mZGU_9k7JQ_jn1DnfTuMdSNprTeaSTyWfSNkuaAwnOEbIQVy1IQbWVV25NY3ybc_IhUJtfri7bAXYEReWaCl3hdlPKXy9UvqPYGR0kIXTQRqns-dVJ7jahlI7LyckrpTmrM8dWBo4_PMaenNnPiQgO0xnuToxutRZJfJvG4Ox4ka3GORQd9CsCZ2vsUDmsXOfUENOyMqADC6p1M3h33tsurY15k9qMSpG9OX_IJAXmxzAh_tWiZOwk2K4yxH9tS3Lq1yX8C1EWmeRDkK2ahecG85-oLKQt5VEpWHKmjOi_gJSdSgqcN96X52esAQ","kty":"RSA"} diff --git a/examples/enclite.db b/examples/enclite.db Binary files differnew file mode 100644 index 0000000..ab78258 --- /dev/null +++ b/examples/enclite.db diff --git a/enclite.sample.key b/examples/enclite.sample.key index debda57..debda57 100644 --- a/enclite.sample.key +++ b/examples/enclite.sample.key diff --git a/tests/tests.py b/tests/tests.py index 4eaae13..8a1091d 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,12 +1,14 @@ from custodia.secrets import SecretsTests from custodia.store.sqlite import SqliteStoreTests +from custodia.message.kem import KEMTests import unittest if __name__ == '__main__': testLoad = unittest.TestLoader() allUnitTests = [testLoad.loadTestsFromTestCase(SecretsTests), - testLoad.loadTestsFromTestCase(SqliteStoreTests)] + testLoad.loadTestsFromTestCase(SqliteStoreTests), + testLoad.loadTestsFromTestCase(KEMTests)] allTestsSuite = unittest.TestSuite(allUnitTests) |