From 5df7db400767dccce10820f5ceaa117fd9f79bf3 Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Mon, 7 Aug 2017 11:49:12 +0200 Subject: [PATCH] Add asn1crypto support Closes: #33 Signed-off-by: Christian Heimes --- .gitignore | 1 + .travis.yml | 16 ++-- kdcproxy/codec.py | 95 +++++++++++------------ kdcproxy/exceptions.py | 30 ++++++++ kdcproxy/parse_asn1crypto.py | 104 ++++++++++++++++++++++++++ kdcproxy/{asn1.py => parse_pyasn1.py} | 52 +++++++++++++ setup.py | 4 +- tests.py | 44 ++++++++--- tox.ini | 24 ++++-- 9 files changed, 296 insertions(+), 74 deletions(-) create mode 100644 kdcproxy/exceptions.py create mode 100644 kdcproxy/parse_asn1crypto.py rename kdcproxy/{asn1.py => parse_pyasn1.py} (64%) diff --git a/.gitignore b/.gitignore index f4ad6ac..3636a3e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__ /MANIFEST /*.egg-info /.cache +/.coverage.* diff --git a/.travis.yml b/.travis.yml index 4fe20c7..c5c00a7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,16 +7,20 @@ cache: pip matrix: include: - python: 2.7 - env: TOXENV=py27 - - python: 3.4 - env: TOXENV=py34 + env: TOXENV=py27-asn1crypto - python: 3.5 - env: TOXENV=py35 + env: TOXENV=py35-asn1crypto - python: 3.6 - env: TOXENV=py36 + env: TOXENV=py36-asn1crypto - python: 2.7 - env: TOXENV=pep8 + env: TOXENV=py27-pyasn1 - python: 3.5 + env: TOXENV=py35-pyasn1 + - python: 3.6 + env: TOXENV=py36-pyasn1 + - python: 2.7 + env: TOXENV=pep8 + - python: 3.6 env: TOXENV=py3pep8 install: diff --git a/kdcproxy/codec.py b/kdcproxy/codec.py index 09be7c4..8942f59 100644 --- a/kdcproxy/codec.py +++ b/kdcproxy/codec.py @@ -19,19 +19,35 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import os import struct -from pyasn1 import error -from pyasn1.codec.der import decoder, encoder - -import kdcproxy.asn1 as asn1 - - -class ParsingError(Exception): - - def __init__(self, message): - super(ParsingError, self).__init__(message) - self.message = message +from kdcproxy.exceptions import ParsingError + +ASN1MOD = os.environ.get('KDCPROXY_ASN1MOD') + +if ASN1MOD is None: + try: + from asn1crypto.version import __version_info__ as asn1crypto_version + except ImportError: + asn1crypto_version = None + else: + if asn1crypto_version >= (0, 22, 0): + ASN1MOD = 'asn1crypto' + if ASN1MOD is None: + try: + __import__('pyasn1') + except ImportError: + pass + else: + ASN1MOD = 'pyasn1' + +if ASN1MOD == 'asn1crypto': + from kdcproxy import parse_asn1crypto as asn1mod +elif ASN1MOD == 'pyasn1': + from kdcproxy import parse_pyasn1 as asn1mod +else: + raise ValueError("Invalid KDCPROXY_ASN1MOD='{}'".format(ASN1MOD)) class ProxyRequest(object): @@ -40,16 +56,7 @@ class ProxyRequest(object): @classmethod def parse(cls, data): - (req, err) = decoder.decode(data, asn1Spec=asn1.ProxyMessage()) - if err: - raise ParsingError("Invalid request.") - - request = req.getComponentByName('message').asOctets() - realm = req.getComponentByName('realm').asOctets() - try: # Python 3.x - realm = str(realm, "UTF8") - except TypeError: # Python 2.x - realm = str(realm) + request, realm, _ = asn1mod.decode_proxymessage(data) # Check the length of the whole request message. (length, ) = struct.unpack("!I", request[0:4]) @@ -58,42 +65,41 @@ def parse(cls, data): for subcls in cls.__subclasses__(): try: - (req, err) = decoder.decode(request[subcls.OFFSET:], - asn1Spec=subcls.TYPE()) - return subcls(realm, request, err) - except error.PyAsn1Error: + return subcls.parse_request(realm, request) + except ParsingError: pass raise ParsingError("Invalid request.") - def __init__(self, realm, request, err): + @classmethod + def parse_request(cls, realm, request): + pretty_name = asn1mod.try_decode(request[cls.OFFSET:], cls.TYPE) + return cls(realm, request, pretty_name) + + def __init__(self, realm, request, pretty_name): self.realm = realm self.request = request - - if len(err) > 0: - type = self.__class__.__name__[:0 - len(ProxyRequest.__name__)] - raise ParsingError("%s request has %d extra bytes." % - (type, len(err))) + self.pretty_name = pretty_name def __str__(self): - type = self.__class__.__name__[:0 - len(ProxyRequest.__name__)] - return "%s %s-REQ (%d bytes)" % (self.realm, type, - len(self.request) - 4) + return "%s %s (%d bytes)" % (self.realm, self.pretty_name, + len(self.request) - 4) class TGSProxyRequest(ProxyRequest): - TYPE = asn1.TGSREQ + TYPE = asn1mod.TGSREQ class ASProxyRequest(ProxyRequest): - TYPE = asn1.ASREQ + TYPE = asn1mod.ASREQ class KPASSWDProxyRequest(ProxyRequest): - TYPE = asn1.APREQ + TYPE = asn1mod.APREQ OFFSET = 10 - def __init__(self, realm, request, err): + @classmethod + def parse_request(cls, realm, request): # Check the length count in the password change request, assuming it # actually is a password change request. It should be the length of # the rest of the request, including itself. @@ -118,13 +124,12 @@ def __init__(self, realm, request, err): # See if the tag looks like an AP request, which would look like the # start of a password change request. The rest of it should be a # KRB-PRIV message. - (apreq, err) = decoder.decode(request[10:length + 10], - asn1Spec=asn1.APREQ()) - (krbpriv, err) = decoder.decode(request[length + 10:], - asn1Spec=asn1.KRBPriv()) + asn1mod.try_decode(request[10:length + 10], asn1mod.APREQ) + asn1mod.try_decode(request[length + 10:], asn1mod.KRBPriv) - super(KPASSWDProxyRequest, self).__init__(realm, request, err) + self = cls(realm, request, "KPASSWD-REQ") self.version = version + return self def __str__(self): tmp = super(KPASSWDProxyRequest, self).__str__() @@ -137,6 +142,4 @@ def decode(data): def encode(data): - rep = asn1.ProxyMessage() - rep.setComponentByName('message', data) - return encoder.encode(rep) + return asn1mod.encode_proxymessage(data) diff --git a/kdcproxy/exceptions.py b/kdcproxy/exceptions.py new file mode 100644 index 0000000..108cd6e --- /dev/null +++ b/kdcproxy/exceptions.py @@ -0,0 +1,30 @@ +# Copyright (C) 2017, Red Hat, Inc. +# All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +class ParsingError(Exception): + def __init__(self, message): + super(ParsingError, self).__init__(message) + self.message = message + + +class ASN1ParsingError(ParsingError): + pass diff --git a/kdcproxy/parse_asn1crypto.py b/kdcproxy/parse_asn1crypto.py new file mode 100644 index 0000000..ac840d0 --- /dev/null +++ b/kdcproxy/parse_asn1crypto.py @@ -0,0 +1,104 @@ +# Copyright (C) 2017, Red Hat, Inc. +# All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from asn1crypto import core + +from kdcproxy.exceptions import ASN1ParsingError + + +APPLICATION = 1 + + +class KerberosString(core.GeneralString): + """KerberosString ::= GeneralString (IA5String) + + For compatibility, implementations MAY choose to accept GeneralString + values that contain characters other than those permitted by + IA5String... + """ + + +class Realm(KerberosString): + """Realm ::= KerberosString + """ + + +class ProxyMessage(core.Sequence): + pretty_name = 'KDC-PROXY-MESSAGE' + + _fields = [ + ('kerb-message', core.OctetString, { + 'explicit': 0}), + ('target-domain', Realm, { + 'explicit': 1, 'optional': True}), + ('dclocator-hint', core.Integer, { + 'explicit': 2, 'optional': True}), + ] + + +class ASREQ(core.Sequence): + pretty_name = 'AS-REQ' + + explicit = (APPLICATION, 10) + + +class TGSREQ(core.Sequence): + pretty_name = 'TGS-REQ' + + explicit = (APPLICATION, 12) + + +class APREQ(core.Sequence): + pretty_name = 'AP-REQ' + + explicit = (APPLICATION, 14) + + +class KRBPriv(core.Sequence): + pretty_name = 'KRBPRiv' + + explicit = (APPLICATION, 21) + + +def decode_proxymessage(data): + req = ProxyMessage.load(data, strict=True) + message = req['kerb-message'].native + realm = req['target-domain'].native + try: # Python 3.x + realm = str(realm, "utf-8") + except TypeError: # Python 2.x + realm = str(realm) + flags = req['dclocator-hint'].native + return message, realm, flags + + +def encode_proxymessage(data): + rep = ProxyMessage() + rep['kerb-message'] = data + return rep.dump() + + +def try_decode(data, cls): + try: + req = cls.load(data, strict=True) + except ValueError as e: + raise ASN1ParsingError(e) + return req.pretty_name diff --git a/kdcproxy/asn1.py b/kdcproxy/parse_pyasn1.py similarity index 64% rename from kdcproxy/asn1.py rename to kdcproxy/parse_pyasn1.py index ab14dc2..8166cdf 100644 --- a/kdcproxy/asn1.py +++ b/kdcproxy/parse_pyasn1.py @@ -19,8 +19,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +from pyasn1 import error +from pyasn1.codec.der import decoder, encoder from pyasn1.type import char, namedtype, tag, univ +from kdcproxy.exceptions import ASN1ParsingError, ParsingError + class ProxyMessageKerberosMessage(univ.OctetString): tagSet = univ.OctetString.tagSet.tagExplicitly( @@ -41,6 +45,8 @@ class ProxyMessageDCLocateHint(univ.Integer): class ProxyMessage(univ.Sequence): + pretty_name = 'KDC-PROXY-MESSAGE' + componentType = namedtype.NamedTypes( namedtype.NamedType('message', ProxyMessageKerberosMessage()), namedtype.OptionalNamedType('realm', ProxyMessageTargetDomain()), @@ -49,24 +55,70 @@ class ProxyMessage(univ.Sequence): class ASREQ(univ.Sequence): + pretty_name = 'AS-REQ' + tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 10) ) class TGSREQ(univ.Sequence): + pretty_name = 'TGS-REQ' + tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 12) ) class APREQ(univ.Sequence): + pretty_name = 'AP-REQ' + tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 14) ) class KRBPriv(univ.Sequence): + pretty_name = 'KRBPRiv' + tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 21) ) + + +def decode_proxymessage(data): + try: + req, tail = decoder.decode(data, asn1Spec=ProxyMessage()) + except error.PyAsn1Error as e: + raise ASN1ParsingError(e) + if tail: + raise ParsingError("Invalid request.") + message = req.getComponentByName('message').asOctets() + realm = req.getComponentByName('realm') + if realm.hasValue(): + try: # Python 3.x + realm = str(realm, "utf-8") + except TypeError: # Python 2.x + realm = str(realm) + else: + realm = None + flags = req.getComponentByName('flags') + flags = int(flags) if flags.hasValue() else None + return message, realm, flags + + +def encode_proxymessage(data): + rep = ProxyMessage() + rep.setComponentByName('message', data) + return encoder.encode(rep) + + +def try_decode(data, cls): + try: + req, tail = decoder.decode(data, asn1Spec=cls()) + except error.PyAsn1Error as e: + raise ASN1ParsingError(e) + if tail: + raise ParsingError("%s request has %d extra bytes." % + (cls.pretty_name, len(tail))) + return cls.pretty_name diff --git a/setup.py b/setup.py index 42c4942..6810175 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ SETUPTOOLS_VERSION = tuple(int(v) for v in setuptools.__version__.split(".")) install_requires = [ - 'pyasn1', + 'asn1crypto>=0.23', ] extras_require = { @@ -57,7 +57,7 @@ def read(fname): setup( name="kdcproxy", - version="0.3.3", + version="0.4.dev1", author="Nalin Dahyabhai, Nathaniel McCallum, Christian Heimes", author_email="nalin@redhat.com, npmccallum@redhat.com, cheimes@redhat.com", description=("A kerberos KDC HTTP proxy WSGI module."), diff --git a/tests.py b/tests.py index 9679823..992529f 100644 --- a/tests.py +++ b/tests.py @@ -20,6 +20,7 @@ # THE SOFTWARE. import os +import sys import unittest from base64 import b64decode try: @@ -32,16 +33,14 @@ from dns.rdatatype import SRV as RDTYPE_SRV from dns.rdtypes.IN.SRV import SRV -from pyasn1.codec.der import decoder, encoder - from webtest import TestApp as WebTestApp import kdcproxy -# from kdcproxy import asn1 from kdcproxy import codec from kdcproxy import config from kdcproxy.config import mit + HERE = os.path.dirname(os.path.abspath(__file__)) KRB5_CONFIG = os.path.join(HERE, 'tests.krb5.conf') @@ -184,18 +183,24 @@ class KDCProxyCodecTests(unittest.TestCase): """) def assert_decode(self, data, cls): + # manual decode + request, realm, _ = codec.asn1mod.decode_proxymessage(data) + self.assertEqual(realm, self.realm) + inst = cls.parse_request(realm, request) + self.assertIsInstance(inst, cls) + self.assertEqual(inst.realm, self.realm) + self.assertEqual(inst.request, request) + if cls is codec.KPASSWDProxyRequest: + self.assertEqual(inst.version, 1) + # codec decode outer = codec.decode(data) self.assertEqual(outer.realm, self.realm) self.assertIsInstance(outer, cls) - if cls is not codec.KPASSWDProxyRequest: - inner, err = decoder.decode(outer.request[outer.OFFSET:], - asn1Spec=outer.TYPE()) - if err: # pragma: no cover - self.fail(err) - self.assertIsInstance(inner, outer.TYPE) - der = encoder.encode(inner) - encoded = codec.encode(der) - self.assertIsInstance(encoded, bytes) + # re-decode + der = codec.encode(outer.request) + self.assertIsInstance(der, bytes) + decoded = codec.decode(der) + self.assertIsInstance(decoded, cls) return outer def test_asreq(self): @@ -216,6 +221,21 @@ def test_kpasswdreq(self): 'FREEIPA.LOCAL KPASSWD-REQ (603 bytes) (version 0x0001)' ) + def test_asn1mod(self): + modmap = { + 'asn1crypto': ( + 'kdcproxy.parse_asn1crypto', 'kdcproxy.parse_pyasn1'), + 'pyasn1': ( + 'kdcproxy.parse_pyasn1', 'kdcproxy.parse_asn1crypto'), + } + asn1mod = os.environ.get('KDCPROXY_ASN1MOD', None) + if asn1mod is None: + self.fail("Tests require KDCPROXY_ASN1MOD env var.") + self.assertIn(asn1mod, modmap) + mod, opposite = modmap[asn1mod] + self.assertIn(mod, set(sys.modules)) + self.assertNotIn(opposite, set(sys.modules)) + class KDCProxyConfigTests(unittest.TestCase): diff --git a/tox.ini b/tox.ini index 63a85ef..d38cb0c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,19 +1,27 @@ [tox] minversion = 2.3.1 -envlist = py27,py34,py35,py36,pep8,py3pep8,doc +envlist = {py27,py35,py36}-{asn1crypto,pyasn1},pep8,py3pep8,doc,coverage-report skip_missing_interpreters = true [testenv] deps = - .[tests] + .[tests] + py27: mock + pyasn1: pyasn1 + asn1crypto: asn1crypto>=0.23 +setenv = + asn1crypto: KDCPROXY_ASN1MOD=asn1crypto + pyasn1: KDCPROXY_ASN1MOD=pyasn1 commands = - {envpython} -m coverage run -m pytest --capture=no --strict {posargs} - {envpython} -m coverage report -m + {envpython} -m coverage run --parallel \ + -m pytest --capture=no --strict {posargs} -[testenv:py27] -deps = - .[tests] - mock +[testenv:coverage-report] +deps = coverage +skip_install = true +commands = + {envpython} -m coverage combine + {envpython} -m coverage report --show-missing [testenv:pep8] basepython = python2.7