From 3b7eed15c3f9da7381d240a762b0e557dd18ce96 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Tue, 27 Oct 2015 14:47:35 -0400 Subject: Add support in the client for the kem message type This allows to easily use end-to-end encrypted requests and replies to fetch secrets. Signed-off-by: Simo Sorce --- API.md | 4 ++ custodia/client.py | 124 +++++++++++++++++++++++++++++++++++++++++++++ custodia/message/common.py | 1 + custodia/message/kem.py | 14 +++-- custodia/message/simple.py | 5 ++ custodia/secrets.py | 89 ++++++++++++++++++++++++++------ tests/custodia.py | 57 +++++++++++++++++++-- 7 files changed, 270 insertions(+), 24 deletions(-) diff --git a/API.md b/API.md index ead0344..16d7eff 100644 --- a/API.md +++ b/API.md @@ -153,6 +153,7 @@ Returns: - 401 if authentication is necessary - 403 if access to the key is forbidden - 404 if no key was found +- 406 not acceptable, type unknown/not permitted Listing containers @@ -171,6 +172,7 @@ Returns: - 401 if authentication is necessary - 403 if access to the key is forbidden - 404 if no key was found +- 406 not acceptable, type unknown/not permitted Creating containers @@ -188,6 +190,7 @@ Returns: - 401 if authentication is necessary - 403 if access to the key is forbidden - 404 one of the elements of the path is not a valid container +- 406 not acceptable, type unknown/not permitted - 409 if the container already exsts @@ -202,4 +205,5 @@ Returns: - 401 if authentication is necessary - 403 if access to the container is forbidden - 404 if no container was found +- 406 not acceptable, type unknown/not permitted - 409 if the container is not empty diff --git a/custodia/client.py b/custodia/client.py index 221080a..9647d68 100644 --- a/custodia/client.py +++ b/custodia/client.py @@ -2,6 +2,9 @@ import socket +from jwcrypto.common import json_decode +from jwcrypto.jwk import JWK + import requests from requests.adapters import HTTPAdapter @@ -10,6 +13,10 @@ from requests.compat import unquote, urlparse from requests.packages.urllib3.connection import HTTPConnection from requests.packages.urllib3.connectionpool import HTTPConnectionPool +from custodia.message.kem import ( + check_kem_claims, decode_enc_kem, make_enc_kem +) + class HTTPUnixConnection(HTTPConnection): @@ -150,3 +157,120 @@ class CustodiaSimpleClient(CustodiaHTTPClient): def del_secret(self, name): r = self.delete(name) r.raise_for_status() + + +class CustodiaKEMClient(CustodiaHTTPClient): + def __init__(self, *args, **kwargs): + super(CustodiaKEMClient, self).__init__(*args, **kwargs) + self._cli_signing_key = None + self._cli_decryption_key = None + self._srv_verifying_key = None + self._srv_encryption_key = None + self._sig_alg = None + self._enc_alg = None + + def _decode_key(self, key): + if key is None: + return None + elif isinstance(key, JWK): + return key + elif isinstance(key, dict): + return JWK(**key) + elif isinstance(key, str): + return JWK(**(json_decode(key))) + else: + raise TypeError("Invalid key type") + + def set_server_public_keys(self, sig, enc): + self._srv_verifying_key = self._decode_key(sig) + self._srv_encryption_key = self._decode_key(enc) + + def set_client_keys(self, sig, enc): + self._cli_signing_key = self._decode_key(sig) + self._cli_decryption_key = self._decode_key(enc) + + def set_algorithms(self, sig, enc): + self._sig_alg = sig + self._enc_alg = enc + + def _signing_algorithm(self, key): + if self._sig_alg is not None: + return self._sig_alg + elif key.key_type == 'RSA': + return 'RS256' + elif key.key_type == 'EC': + return 'ES256' + else: + raise ValueError('Unsupported key type') + + def _encryption_algorithm(self, key): + if self._enc_alg is not None: + return self._enc_alg + elif key.key_type == 'RSA': + return ('RSA1_5', 'A256CBC-HS512') + elif key.key_type == 'EC': + return ('ECDH-ES+A256KW', 'A256CBC-HS512') + else: + raise ValueError('Unsupported key type') + + def _kem_wrap(self, name, value): + if self._cli_signing_key is None: + raise KeyError("Client Signing key is not available") + if self._srv_encryption_key is None: + raise KeyError("Server Encryption key is not available") + sig_alg = self._signing_algorithm(self._cli_signing_key) + enc_alg = self._encryption_algorithm(self._srv_encryption_key) + return make_enc_kem(name, value, + self._cli_signing_key, sig_alg, + self._srv_encryption_key, enc_alg) + + def _kem_unwrap(self, name, message): + if message.get("type", None) != "kem": + raise TypeError("Invalid token type, expected 'kem', got %s" % ( + message.get("type", None),)) + + if self._cli_decryption_key is None: + raise KeyError("Client Decryption key is not available") + if self._srv_verifying_key is None: + raise KeyError("Server Verifying key is not available") + claims = decode_enc_kem(message["value"], + self._cli_decryption_key, + self._srv_verifying_key) + check_kem_claims(claims, name) + return claims + + def create_container(self, name): + cname = self.container_name(name) + message = self._kem_wrap(cname, None) + r = self.post(cname, json={"type": "kem", "value": message}) + r.raise_for_status() + self._kem_unwrap(cname, r.json()) + + def delete_container(self, name): + cname = self.container_name(name) + message = self._kem_wrap(cname, None) + r = self.delete(cname, json={"type": "kem", "value": message}) + r.raise_for_status() + self._kem_unwrap(cname, r.json()) + + def list_container(self, name): + return json_decode(self.get_secret(self.container_name(name))) + + def get_secret(self, name): + message = self._kem_wrap(name, None) + r = self.get(name, params={"type": "kem", "value": message}) + r.raise_for_status() + claims = self._kem_unwrap(name, r.json()) + return claims['value'] + + def set_secret(self, name, value): + message = self._kem_wrap(name, value) + r = self.put(name, json={"type": "kem", "value": message}) + r.raise_for_status() + self._kem_unwrap(name, r.json()) + + def del_secret(self, name): + message = self._kem_wrap(name, None) + r = self.delete(name, json={"type": "kem", "value": message}) + r.raise_for_status() + self._kem_unwrap(name, r.json()) diff --git a/custodia/message/common.py b/custodia/message/common.py index d774e3c..bbcfb2b 100644 --- a/custodia/message/common.py +++ b/custodia/message/common.py @@ -42,6 +42,7 @@ class MessageHandler(object): def __init__(self, request): self.req = request + self.name = None self.payload = None def parse(self, msg, name): diff --git a/custodia/message/kem.py b/custodia/message/kem.py index 48b756b..add1c72 100644 --- a/custodia/message/kem.py +++ b/custodia/message/kem.py @@ -215,11 +215,9 @@ class KEMClient(object): self.server_keys[KEY_USAGE_ENC], encalg) def parse_reply(self, name, message): - jwe = JWT(jwt=message, - key=self.client_keys[KEY_USAGE_ENC]) - jws = JWT(jwt=jwe.claims, - key=self.server_keys[KEY_USAGE_SIG]) - claims = json_decode(jws.claims) + claims = decode_enc_kem(message, + self.client_keys[KEY_USAGE_ENC], + self.server_keys[KEY_USAGE_SIG]) check_kem_claims(claims, name) return claims['value'] @@ -242,6 +240,12 @@ def make_enc_kem(name, value, sig_key, alg, enc_key, enc): return jwe.serialize(compact=True) +def decode_enc_kem(message, enc_key, sig_key): + jwe = JWT(jwt=message, key=enc_key) + jws = JWT(jwt=jwe.claims, key=sig_key) + return json_decode(jws.claims) + + # unit tests test_keys = ({ "kty": "RSA", diff --git a/custodia/message/simple.py b/custodia/message/simple.py index 7186d12..6482c53 100644 --- a/custodia/message/simple.py +++ b/custodia/message/simple.py @@ -28,8 +28,13 @@ class SimpleKey(MessageHandler): if not isinstance(msg, string_types): raise InvalidMessage("The 'value' attribute is not a string") + self.name = name self.payload = msg def reply(self, output): + if self.name.endswith('/'): + # directory listings are pass-through with simple messages + return output + return json.dumps({'type': 'simple', 'value': output}, separators=(',', ':')) diff --git a/custodia/secrets.py b/custodia/secrets.py index 1c3248d..7735941 100644 --- a/custodia/secrets.py +++ b/custodia/secrets.py @@ -46,8 +46,30 @@ class Secrets(HTTPConsumer): f = self._db_key([default, '']) return f - def _parse(self, request, value, name): - return self._validator.parse(request, value, name) + def _parse(self, request, query, name): + return self._validator.parse(request, query, name) + + def _parse_query(self, request, name): + # default to simple + query = request.get('query', '') + if len(query) == 0: + query = {'type': 'simple', 'value': ''} + return self._parse(request, query, name) + + def _parse_body(self, request, name): + body = request.get('body') + if body is None: + raise HTTPError(400) + value = json.loads(bytes(body).decode('utf-8')) + return self._parse(request, value, name) + + def _parse_maybe_body(self, request, name): + body = request.get('body') + if body is None: + value = {'type': 'simple', 'value': ''} + else: + value = json.loads(bytes(body).decode('utf-8')) + return self._parse(request, value, name) def _parent_exists(self, default, trail): # check that the containers exist @@ -102,6 +124,11 @@ class Secrets(HTTPConsumer): raise HTTPError(405) def _list(self, trail, request, response): + try: + name = '/'.join(trail) + msg = self._parse_query(request, name) + except Exception as e: + raise HTTPError(406, str(e)) default = request.get('default_namespace', None) basename = self._db_container_key(default, trail) try: @@ -109,11 +136,16 @@ class Secrets(HTTPConsumer): self.logger.debug('list %s returned %r', basename, keylist) if keylist is None: raise HTTPError(404) - response['output'] = json.dumps(keylist) + response['output'] = msg.reply(json.dumps(keylist)) except CSStoreError: raise HTTPError(500) def _create(self, trail, request, response): + try: + name = '/'.join(trail) + msg = self._parse_maybe_body(request, name) + except Exception as e: + raise HTTPError(406, str(e)) default = request.get('default_namespace', None) basename = self._db_container_key(None, trail) try: @@ -128,9 +160,17 @@ class Secrets(HTTPConsumer): except CSStoreError: raise HTTPError(500) + output = msg.reply(None) + if output is not None: + response['output'] = output response['code'] = 201 def _destroy(self, trail, request, response): + try: + name = '/'.join(trail) + msg = self._parse_maybe_body(request, name) + except Exception as e: + raise HTTPError(406, str(e)) basename = self._db_container_key(None, trail) try: keylist = self.root.store.list(basename) @@ -145,7 +185,12 @@ class Secrets(HTTPConsumer): if ret is False: raise HTTPError(404) - response['code'] = 204 + output = msg.reply(None) + if output is None: + response['code'] = 204 + else: + response['output'] = output + response['code'] = 200 def _client_name(self, request): if 'remote_user' in request: @@ -171,13 +216,9 @@ class Secrets(HTTPConsumer): self._int_get_key, trail, request, response) def _int_get_key(self, trail, request, response): - # default to simple - query = request.get('query', '') - if len(query) == 0: - query = {'type': 'simple', 'value': ''} try: name = '/'.join(trail) - msg = self._parse(request, query, name) + msg = self._parse_query(request, name) except Exception as e: raise HTTPError(406, str(e)) key = self._db_key(trail) @@ -198,13 +239,9 @@ class Secrets(HTTPConsumer): dict()).get('Content-Type', '') if content_type.split(';')[0].strip() != 'application/json': raise HTTPError(400, 'Invalid Content-Type') - body = request.get('body') - if body is None: - raise HTTPError(400) - value = bytes(body).decode('utf-8') try: name = '/'.join(trail) - msg = self._parse(request, json.loads(value), name) + msg = self._parse_body(request, name) except UnknownMessageType as e: raise HTTPError(406, str(e)) except UnallowedMessage as e: @@ -229,6 +266,9 @@ class Secrets(HTTPConsumer): except CSStoreError: raise HTTPError(500) + output = msg.reply(None) + if output is not None: + response['output'] = output response['code'] = 201 def _del_key(self, trail, request, response): @@ -236,6 +276,11 @@ class Secrets(HTTPConsumer): self._int_del_key, trail, request, response) def _int_del_key(self, trail, request, response): + try: + name = '/'.join(trail) + msg = self._parse_maybe_body(request, name) + except Exception as e: + raise HTTPError(406, str(e)) key = self._db_key(trail) try: ret = self.root.store.cut(key) @@ -245,7 +290,12 @@ class Secrets(HTTPConsumer): if ret is False: raise HTTPError(404) - response['code'] = 204 + output = msg.reply(None) + if output is None: + response['code'] = 204 + else: + response['output'] = output + response['code'] = 200 # unit tests @@ -427,6 +477,15 @@ class SecretsTests(unittest.TestCase): self.GET(req, rep) self.assertEqual(err.exception.code, 404) + def test_6_LISTkeys_errors_406_1(self): + req = {'remote_user': 'test', + 'query': {'type': 'invalid'}, + 'trail': ['test', '']} + rep = {} + with self.assertRaises(HTTPError) as err: + self.GET(req, rep) + self.assertEqual(err.exception.code, 406) + def test_7_DELETEKey(self): req = {'remote_user': 'test', 'trail': ['test', 'key1']} diff --git a/tests/custodia.py b/tests/custodia.py index 9db0e4b..2109e38 100644 --- a/tests/custodia.py +++ b/tests/custodia.py @@ -14,7 +14,8 @@ from jwcrypto import jwk from requests.exceptions import HTTPError -from custodia.client import CustodiaSimpleClient +from custodia.client import CustodiaKEMClient, CustodiaSimpleClient +from custodia.store.sqlite import SqliteStore TEST_CUSTODIA_CONF = """ @@ -83,8 +84,15 @@ master_enctype = A128CBC-HS256 handler = custodia.httpd.authorizers.SimplePathAuthz paths = /enc +[authz:enc_kem] +handler = custodia.message.kem.KEMKeysStore +server_keys = srvkid +store = simple +paths = /enc/kem + [/enc] handler = custodia.secrets.Secrets +allowed_keytypes = simple kem store = encgen """ @@ -100,11 +108,27 @@ def unlink_if_exists(filename): raise -def generate_key(filename): +def generate_all_keys(filename): key = jwk.JWK(generate='oct', size=256) - with (open(filename, 'w+')) as keyfile: + with open(filename, 'w+') as keyfile: keyfile.write(key.export()) + srv_kid = "srvkid" + cli_kid = "clikid" + ss_key = jwk.JWK(generate='RSA', kid=srv_kid, use="sig") + se_key = jwk.JWK(generate='RSA', kid=srv_kid, use="enc") + store = SqliteStore({'dburi': 'test_secrets.db', 'table': 'secrets'}) + store.set('kemkeys/sig/%s' % srv_kid, ss_key.export()) + store.set('kemkeys/enc/%s' % srv_kid, se_key.export()) + + cs_key = jwk.JWK(generate='RSA', kid=cli_kid, use="sig") + ce_key = jwk.JWK(generate='RSA', kid=cli_kid, use="enc") + store = SqliteStore({'dburi': 'test_secrets.db', 'table': 'secrets'}) + store.set('kemkeys/sig/%s' % cli_kid, cs_key.export_public()) + store.set('kemkeys/enc/%s' % cli_kid, ce_key.export_public()) + return ([ss_key.export_public(), se_key.export_public()], + [cs_key.export(), ce_key.export()]) + class CustodiaTests(unittest.TestCase): @@ -122,7 +146,7 @@ class CustodiaTests(unittest.TestCase): cls.socket_url = TEST_SOCKET_URL cls.test_auth_id = "test_user" cls.test_auth_key = "cd54b735-e756-4f12-aa18-d85509baef36" - generate_key('test_mkey.conf') + (srvkeys, clikeys) = generate_all_keys('test_mkey.conf') with (open('test_custodia.conf', 'w+')) as conffile: t = Template(TEST_CUSTODIA_CONF) conf = t.substitute({'SOCKET_URL': cls.socket_url, @@ -149,6 +173,11 @@ class CustodiaTests(unittest.TestCase): cls.enc = CustodiaSimpleClient(cls.socket_url + '/enc') cls.enc.headers['REMOTE_USER'] = 'enc' + cls.kem = CustodiaKEMClient(cls.socket_url + '/enc') + cls.kem.headers['REMOTE_USER'] = 'kem' + cls.kem.set_server_public_keys(*srvkeys) + cls.kem.set_client_keys(*clikeys) + @classmethod def tearDownClass(cls): cls.custodia_process.kill() @@ -223,3 +252,23 @@ class CustodiaTests(unittest.TestCase): self.assertNotEqual(key, 'simple') key = self.enc.get_secret('enc/key') self.assertEqual(key, 'simple') + + def test_B_1_kem_create_container(self): + self.kem.create_container('kem') + cl = self.kem.list_container('kem') + self.assertEqual(cl, []) + self.kem.set_secret('kem/key', 'Protected') + cl = self.kem.list_container('kem') + self.assertEqual(cl, ['key']) + value = self.kem.get_secret('kem/key') + self.assertEqual(value, 'Protected') + self.kem.del_secret('kem/key') + try: + self.kem.get_secret('kem/key') + except HTTPError: + self.assertEqual(self.kem.last_response.status_code, 404) + self.kem.delete_container('kem') + try: + self.kem.list_container('kem') + except HTTPError: + self.assertEqual(self.kem.last_response.status_code, 404) -- cgit