summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimo Sorce <simo@redhat.com>2015-04-20 16:08:28 -0400
committerSimo Sorce <simo@redhat.com>2015-04-27 15:06:26 -0400
commita2ed51acfdff399a6ad6cd486eb22da9acf59280 (patch)
tree1b49d6338e2322450efe9da92c655012105cd3cd
parentb9e31bf1cc44bdfeaf0454dadb578c4dbb8d588b (diff)
downloadcustodia-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.md59
-rw-r--r--custodia.conf17
-rw-r--r--custodia/message/formats.py6
-rw-r--r--custodia/message/kem.py275
-rw-r--r--examples/client_enc.key1
-rw-r--r--examples/enclite.dbbin0 -> 7168 bytes
-rw-r--r--examples/enclite.sample.key (renamed from enclite.sample.key)0
-rw-r--r--tests/tests.py4
8 files changed, 355 insertions, 7 deletions
diff --git a/API.md b/API.md
index 91d9f2b..aff7ba2 100644
--- a/API.md
+++ b/API.md
@@ -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
new file mode 100644
index 0000000..ab78258
--- /dev/null
+++ b/examples/enclite.db
Binary files differ
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)