Import Upstream version 4.12.4
This commit is contained in:
0
ipaserver/custodia/message/__init__.py
Normal file
0
ipaserver/custodia/message/__init__.py
Normal file
68
ipaserver/custodia/message/common.py
Normal file
68
ipaserver/custodia/message/common.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2015 Custodia Project Contributors - see LICENSE file
|
||||
from __future__ import absolute_import
|
||||
|
||||
from ipaserver.custodia.log import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidMessage(Exception):
|
||||
"""Invalid Message.
|
||||
|
||||
This exception is raised when a message cannot be parsed
|
||||
or validated.
|
||||
"""
|
||||
def __init__(self, message=None):
|
||||
logger.debug(message)
|
||||
super(InvalidMessage, self).__init__(message)
|
||||
|
||||
|
||||
class UnknownMessageType(Exception):
|
||||
"""Unknown Message Type.
|
||||
|
||||
This exception is raised when a message is of an unknown
|
||||
type.
|
||||
"""
|
||||
def __init__(self, message=None):
|
||||
logger.debug(message)
|
||||
super(UnknownMessageType, self).__init__(message)
|
||||
|
||||
|
||||
class UnallowedMessage(Exception):
|
||||
"""Unallowed Message.
|
||||
|
||||
This exception is raise when the message type is know but
|
||||
is not allowed.
|
||||
"""
|
||||
def __init__(self, message=None):
|
||||
logger.debug(message)
|
||||
super(UnallowedMessage, self).__init__(message)
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
|
||||
def __init__(self, request):
|
||||
self.req = request
|
||||
self.name = None
|
||||
self.payload = None
|
||||
self.msg_type = None
|
||||
|
||||
def parse(self, msg, name):
|
||||
"""Parses the message.
|
||||
|
||||
:param req: the original request
|
||||
:param msg: a decoded json string with the incoming message
|
||||
|
||||
:raises InvalidMessage: if the message cannot be parsed or validated
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def reply(self, output):
|
||||
"""Generates a reply.
|
||||
|
||||
:param req: the original request
|
||||
:param output: a Python object that can be converted to JSON
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
64
ipaserver/custodia/message/formats.py
Normal file
64
ipaserver/custodia/message/formats.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright (C) 2015 Custodia Project Contributors - see LICENSE file
|
||||
from __future__ import absolute_import
|
||||
|
||||
from ipaserver.custodia.message.common import InvalidMessage
|
||||
from ipaserver.custodia.message.common import UnallowedMessage
|
||||
from ipaserver.custodia.message.common import UnknownMessageType
|
||||
from ipaserver.custodia.message.kem import KEMHandler
|
||||
from ipaserver.custodia.message.simple import SimpleKey
|
||||
|
||||
|
||||
default_types = ['simple', 'kem']
|
||||
|
||||
key_types = {'simple': SimpleKey,
|
||||
'kem': KEMHandler}
|
||||
|
||||
|
||||
class Validator:
|
||||
"""Validates incoming messages."""
|
||||
|
||||
def __init__(self, allowed=None):
|
||||
"""Creates a Validator object.
|
||||
|
||||
:param allowed: list of allowed message types (optional)
|
||||
"""
|
||||
self.allowed = allowed or default_types
|
||||
self.types = key_types.copy()
|
||||
|
||||
def add_types(self, types):
|
||||
self.types.update(types)
|
||||
|
||||
def parse(self, request, msg, name):
|
||||
if not isinstance(msg, dict):
|
||||
raise InvalidMessage('The message must be a dict')
|
||||
|
||||
if 'type' not in msg:
|
||||
raise InvalidMessage('The type is missing')
|
||||
|
||||
if isinstance(msg['type'], list):
|
||||
if len(msg['type']) != 1:
|
||||
raise InvalidMessage('Type is multivalued: %s' % msg['type'])
|
||||
msg_type = msg['type'][0]
|
||||
else:
|
||||
msg_type = msg['type']
|
||||
|
||||
if 'value' not in msg:
|
||||
raise InvalidMessage('The value is missing')
|
||||
|
||||
if isinstance(msg['value'], list):
|
||||
if len(msg['value']) != 1:
|
||||
raise InvalidMessage('Value is multivalued: %s' % msg['value'])
|
||||
msg_value = msg['value'][0]
|
||||
else:
|
||||
msg_value = msg['value']
|
||||
|
||||
if msg_type not in self.types:
|
||||
raise UnknownMessageType("Type '%s' is unknown" % msg_type)
|
||||
|
||||
if msg_type not in self.allowed:
|
||||
raise UnallowedMessage("Message type '%s' not allowed" % (
|
||||
msg_type,))
|
||||
|
||||
handler = self.types[msg_type](request)
|
||||
handler.parse(msg_value, name)
|
||||
return handler
|
||||
247
ipaserver/custodia/message/kem.py
Normal file
247
ipaserver/custodia/message/kem.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# Copyright (C) 2015 Custodia Project Contributors - see LICENSE file
|
||||
from __future__ import absolute_import
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
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
|
||||
|
||||
from ipaserver.custodia.httpd.authorizers import SimplePathAuthz
|
||||
from ipaserver.custodia.log import getLogger
|
||||
from ipaserver.custodia.message.common import InvalidMessage
|
||||
from ipaserver.custodia.message.common import MessageHandler
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
KEY_USAGE_SIG = 0
|
||||
KEY_USAGE_ENC = 1
|
||||
KEY_USAGE_MAP = {KEY_USAGE_SIG: 'sig', KEY_USAGE_ENC: 'enc'}
|
||||
|
||||
|
||||
class UnknownPublicKey(Exception):
|
||||
def __init__(self, message=None):
|
||||
logger.debug(message)
|
||||
super(UnknownPublicKey, self).__init__(message)
|
||||
|
||||
|
||||
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 appropriate key to verify/decrypt an
|
||||
incoming request and make the payload available.
|
||||
|
||||
The KEM Parser will actually perform 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):
|
||||
super(KEMKeysStore, self).__init__(config)
|
||||
self._server_keys = 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, usage):
|
||||
dbkey = self._db_key('%s/%s' % (KEY_USAGE_MAP[usage], kid))
|
||||
pubkey = self.store.get(dbkey)
|
||||
if pubkey is None:
|
||||
raise UnknownPublicKey(kid)
|
||||
return pubkey
|
||||
|
||||
@property
|
||||
def server_keys(self):
|
||||
if self._server_keys is None:
|
||||
if 'server_keys' not in self.config:
|
||||
raise UnknownPublicKey("Server Keys not defined")
|
||||
skey = self.find_key(self.config['server_keys'], KEY_USAGE_SIG)
|
||||
ekey = self.find_key(self.config['server_keys'], KEY_USAGE_ENC)
|
||||
self._server_keys = [JWK(**(json_decode(skey))),
|
||||
JWK(**(json_decode(ekey)))]
|
||||
return self._server_keys
|
||||
|
||||
@property
|
||||
def alg(self):
|
||||
if self._alg is None:
|
||||
alg = self.config.get('signing_algorithm', None)
|
||||
if alg is None:
|
||||
ktype = self.server_keys[KEY_USAGE_SIG]['kty']
|
||||
if ktype == 'RSA':
|
||||
alg = 'RS256'
|
||||
elif ktype == 'EC':
|
||||
alg = 'ES256'
|
||||
else:
|
||||
raise ValueError('Key type unsupported for signing')
|
||||
self._alg = alg
|
||||
return self._alg
|
||||
|
||||
|
||||
def check_kem_claims(claims, name):
|
||||
if 'sub' not in claims:
|
||||
raise InvalidMessage('Missing subject in payload')
|
||||
if claims['sub'] != name:
|
||||
raise InvalidMessage('Key name %s does not match subject %s' % (
|
||||
name, claims['sub']))
|
||||
if 'exp' not in claims:
|
||||
raise InvalidMessage('Missing expiration time in payload')
|
||||
if claims['exp'] - (10 * 60) > int(time.time()):
|
||||
raise InvalidMessage('Message expiration too far in the future')
|
||||
if claims['exp'] < int(time.time()):
|
||||
raise InvalidMessage('Message Expired')
|
||||
|
||||
|
||||
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_keys = None
|
||||
self.name = None
|
||||
|
||||
def _get_key(self, header, usage):
|
||||
if 'kid' not in header:
|
||||
raise InvalidMessage("Missing key identifier")
|
||||
|
||||
key = self.kkstore.find_key(header.get('kid'), usage)
|
||||
if key is None:
|
||||
raise UnknownPublicKey('Key found [kid:%s]' % header.get('kid'))
|
||||
return json_decode(key)
|
||||
|
||||
def parse(self, msg, name):
|
||||
"""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' % str(e))
|
||||
|
||||
try:
|
||||
token = jtok.token
|
||||
if isinstance(token, JWE):
|
||||
token.decrypt(self.kkstore.server_keys[KEY_USAGE_ENC])
|
||||
# If an encrypted payload is received then there must be
|
||||
# a nested signed payload to verify the provenance.
|
||||
payload = token.payload.decode('utf-8')
|
||||
token = JWS()
|
||||
token.deserialize(payload)
|
||||
elif isinstance(token, JWS):
|
||||
pass
|
||||
else:
|
||||
raise TypeError("Invalid Token type: %s" % type(jtok))
|
||||
|
||||
# Retrieve client keys for later use
|
||||
self.client_keys = [
|
||||
JWK(**self._get_key(token.jose_header, KEY_USAGE_SIG)),
|
||||
JWK(**self._get_key(token.jose_header, KEY_USAGE_ENC))]
|
||||
|
||||
# verify token and get payload
|
||||
token.verify(self.client_keys[KEY_USAGE_SIG])
|
||||
claims = json_decode(token.payload)
|
||||
except Exception as e:
|
||||
logger.debug('Failed to validate message', exc_info=True)
|
||||
raise InvalidMessage('Failed to validate message: %s' % str(e))
|
||||
|
||||
check_kem_claims(claims, name)
|
||||
self.name = name
|
||||
self.payload = claims.get('value')
|
||||
self.msg_type = 'kem'
|
||||
|
||||
return {'type': self.msg_type,
|
||||
'value': {'kid': self.client_keys[KEY_USAGE_ENC].get('kid'),
|
||||
'claims': claims}}
|
||||
|
||||
def reply(self, output):
|
||||
if self.client_keys is None:
|
||||
raise UnknownPublicKey("Peer key not defined")
|
||||
|
||||
ktype = self.client_keys[KEY_USAGE_ENC]['kty']
|
||||
if ktype == 'RSA':
|
||||
enc = ('RSA-OAEP', 'A256CBC-HS512')
|
||||
else:
|
||||
raise ValueError("'%s' type not supported yet" % ktype)
|
||||
|
||||
value = make_enc_kem(self.name, output,
|
||||
self.kkstore.server_keys[KEY_USAGE_SIG],
|
||||
self.kkstore.alg,
|
||||
self.client_keys[1], enc)
|
||||
|
||||
return {'type': 'kem', 'value': value}
|
||||
|
||||
|
||||
class KEMClient:
|
||||
|
||||
def __init__(self, server_keys, client_keys):
|
||||
self.server_keys = server_keys
|
||||
self.client_keys = client_keys
|
||||
|
||||
def make_request(self, name, value=None, alg="RS256", encalg=None):
|
||||
if encalg is None:
|
||||
return make_sig_kem(name, value,
|
||||
self.client_keys[KEY_USAGE_SIG], alg)
|
||||
else:
|
||||
return make_enc_kem(name, value,
|
||||
self.client_keys[KEY_USAGE_SIG], alg,
|
||||
self.server_keys[KEY_USAGE_ENC], encalg)
|
||||
|
||||
def parse_reply(self, name, message):
|
||||
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']
|
||||
|
||||
|
||||
def make_sig_kem(name, value, key, alg):
|
||||
header = {'kid': key.get('kid'), 'alg': alg}
|
||||
claims = {'sub': name, 'exp': int(time.time() + (5 * 60))}
|
||||
if value is not None:
|
||||
claims['value'] = value
|
||||
jwt = JWT(header, claims)
|
||||
jwt.make_signed_token(key)
|
||||
return jwt.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.get('kid'), 'alg': enc[0], 'enc': enc[1]}
|
||||
jwe = JWE(plaintext, json_encode(eprot))
|
||||
jwe.add_recipient(enc_key)
|
||||
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)
|
||||
42
ipaserver/custodia/message/simple.py
Normal file
42
ipaserver/custodia/message/simple.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Copyright (C) 2015 Custodia Project Contributors - see LICENSE file
|
||||
from __future__ import absolute_import
|
||||
|
||||
from six import string_types
|
||||
|
||||
from ipaserver.custodia.message.common import InvalidMessage
|
||||
from ipaserver.custodia.message.common import MessageHandler
|
||||
|
||||
|
||||
class SimpleKey(MessageHandler):
|
||||
"""Handles 'simple' messages"""
|
||||
|
||||
def parse(self, msg, name):
|
||||
"""Parses a simple message
|
||||
|
||||
:param msg: the json-decoded value
|
||||
:param name: the requested name
|
||||
|
||||
:raises UnknownMessageType: if the type is not 'simple'
|
||||
:raises InvalidMessage: if the message cannot be parsed or validated
|
||||
"""
|
||||
|
||||
# On requests we imply 'simple' if there is no input message
|
||||
if msg is None:
|
||||
return
|
||||
|
||||
if not isinstance(msg, string_types):
|
||||
raise InvalidMessage("The 'value' attribute is not a string")
|
||||
|
||||
self.name = name
|
||||
self.payload = msg
|
||||
self.msg_type = 'simple'
|
||||
|
||||
def reply(self, output):
|
||||
if output is None:
|
||||
return None
|
||||
|
||||
if self.name.endswith('/'):
|
||||
# directory listings are pass-through with simple messages
|
||||
return output
|
||||
|
||||
return {'type': self.msg_type, 'value': output}
|
||||
Reference in New Issue
Block a user