From aa35faaf176b98e84b893cd6467e1422d939611b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 20 Sep 2012 19:59:20 -0500 Subject: [PATCH] Initial commit --- .gitignore | 1 + LICENSE | 21 ++ README.rst | 14 ++ puresasl/__init__.py | 9 + puresasl/mechanism.py | 489 ++++++++++++++++++++++++++++++++++++++++++ puresasl/sasl.py | 85 ++++++++ puresasl/util.py | 115 ++++++++++ setup.py | 92 ++++++++ 8 files changed, 826 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 puresasl/__init__.py create mode 100644 puresasl/mechanism.py create mode 100644 puresasl/sasl.py create mode 100644 puresasl/util.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80a861e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +http://www.opensource.org/licenses/mit-license.php + +Copyright 2007-2011 David Alan Cridland +Copyright 2011 Lance Stout +Copyright 2012 Tyler L Hobbs + +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. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..cbe04b9 --- /dev/null +++ b/README.rst @@ -0,0 +1,14 @@ +PureSASL +======== +PureSASL is a pure python client-side SASL implementation. + +At the moment, it supports the following mechanisms: ANONYMOUS, PLAIN, +CRAM-MD5, DIGEST-MD5, and GSSAPI. Support for other mechanisms may be +added in the future. + +License +------- +Some of the mechanisms and utility functions are based on work done +by David Alan Cridland and Lance Stout in Suelta: https://github.com/dwd/Suelta + +PureSASL is open source under the `MIT license `_. diff --git a/puresasl/__init__.py b/puresasl/__init__.py new file mode 100644 index 0000000..765302d --- /dev/null +++ b/puresasl/__init__.py @@ -0,0 +1,9 @@ +__version__ = '0.1' +__version_info__ = (0, 1, 0) + + +class SASLError(Exception): + pass + +class SASLProtocolException(Exception): + pass diff --git a/puresasl/mechanism.py b/puresasl/mechanism.py new file mode 100644 index 0000000..fdd5536 --- /dev/null +++ b/puresasl/mechanism.py @@ -0,0 +1,489 @@ +import base64 +import hashlib +import hmac +import random +import struct +import sys + +from puresasl import SASLError, SASLProtocolException +from puresasl.util import bytes, num_to_bytes, bytes_to_num, quote + +try: + import kerberos + _have_kerberos = True +except ImportError: + _have_kerberos = False + + +class Mechanism(object): + + name = None + score = 0 + + has_initial_response = False + allows_anonymous = True + uses_plaintext = True + active_safe = False + dictionary_safe = False + + def __init__(self, sasl): + self.sasl = sasl + self.complete = False + + def okay(self): + return self.complete + + def process(self, challenge=None): + """ + Process a challenge request and return the response. + + :param challenge: A challenge issued by the server that + must be answered for authentication. + """ + raise NotImplementedError() + + def wrap(self, outgoing): + raise NotImplementedError() + + def unwrap(self, incoming): + raise NotImplementedError() + + def dispose(self): + """ Clear all sensitive data """ + pass + + def _fetch_properties(self, *properties): + needed = [p for p in properties if getattr(self, p, None) is None] + if needed and not self.sasl.callback: + raise SASLError('The following properties are required, but a ' + 'callback has not been set: %s' % ', '.join(needed)) + + for prop in needed: + setattr(self, prop, self.sasl.callback(prop)) + + def _pick_qop(self, server_offered_qops): + available_qops = set(self.sasl.qops) & set(server_offered_qops) + if not available_qops: + raise SASLProtocolException("Your requested quality of " + "protection is one of (%s), but the server is only " + "offering (%s)" % + (', '.join(self.sasl.qops), ', '.join(server_offered_qops))) + else: + self.qops = available_qops + for qop in ('auth-conf', 'auth-int', 'auth'): + if qop in self.qops: + self.qop = qop + break + + +class AnonymousMechanism(Mechanism): + name = 'ANONYMOUS' + score = 0 + + uses_plaintext = False + + def process(self, challenge=None): + self.complete = True + return b'Anonymous, None' + + +class PlainMechanism(Mechanism): + name = 'PLAIN' + score = 1 + + allows_anonymous = False + + def __init__(self, sasl, username=None, password=None, **props): + """ + """ + Mechanism.__init__(self, sasl) + self.username = username + self.password = password + + def process(self, challenge=None): + self._fetch_properties('username', 'password') + return b'\x00' + bytes(self.user) + b'\x00' + bytes(self.password) + + def dispose(self): + self.password = None + + +class CramMD5Mechanism(PlainMechanism): + name = "CRAM-MD5" + score = 20 + + allows_anonymous = False + uses_plaintext = False + + def __init__(self, sasl, username=None, password=None, **props): + Mechanism.__init__(self, sasl) + self.username = username + self.password = password + + def process(self, challenge=None): + if challenge is None: + return None + + self._fetch_properties('username', 'password') + mac = hmac.HMAC(key=bytes(self.password), digestmod=hashlib.md5) + mac.update(challenge) + return bytes(self.username) + b' ' + bytes(mac.hexdigest()) + + def dispose(self): + self.password = None + + +# TODO: incomplete, not tested +class DigestMD5Mechanism(Mechanism): + + name = "DIGEST-MD5" + score = 30 + + allows_anonymous = False + uses_plaintext = False + + enc_magic = 'Digest session key to client-to-server signing key magic' + dec_magic = 'Digest session key to server-to-client signing key magic' + + def __init__(self, sasl, username=None, password=None, **props): + Mechanism.__init__(self, sasl) + self.username = username + + self.qops = self.sasl.qops + self.qop = b'auth' + self.max_buffer = self.sasl.max_buffer + + self._rspauth_okay = False + self._digest_uri = None + self._a1 = None + self._enc_buf = b'' + self._enc_key = None + self._enc_seq = 0 + self._dec_buf = b'' + self._dec_key = None + self._dec_seq = 0 + + def dispose(self): + self._rspauth_okay = None + self._digest_uri = None + self._a1 = None + self._enc_buf = b'' + self._enc_key = None + self._enc_seq = 0 + self._dec_buf = b'' + self._dec_key = None + self._dec_seq = 0 + + self.password = None + self.key_hash = None + self.realm = None + self.nonce = None + self.cnonce = None + self.nc = 0 + + def _MAC(self, seq, msg, key): + """ + """ + mac = hmac.HMAC(key=key, digestmod=hashlib.md5) + seqnum = num_to_bytes(seq) + mac.update(seqnum) + mac.update(msg) + return mac.digest()[:10] + b'\x00\x01' + seqnum + + def wrap(self, outgoing): + result = b'' + # Leave buffer space for the MAC + mbuf = self.max_buffer - 10 - 2 - 4 + + while outgoing: + msg = outgoing[:mbuf] + mac = self._MAC(self._enc_seq, msg, self._enc_key) + self._enc_seq += 1 + msg += mac + result += num_to_bytes(len(msg)) + msg + outgoing = outgoing[mbuf:] + + return result + + def unwrap(self, incoming): + """ + """ + incoming = b'' + incoming + result = b'' + + while len(incoming) > 4: + num = bytes_to_num(incoming) + if len(incoming) < (num + 4): + return result + + mac = incoming[4:4 + num] + incoming[4 + num:] + msg = mac[:-16] + + mac_conf = self._MAC(self._dec_mac, msg, self._dec_key) + if mac[-16:] != mac_conf: + self._desc_sec = None + return result + + self._dec_seq += 1 + result += msg + + return result + + def response(self): + required_props = ['username'] + if not getattr(self, 'key_hash', None): + required_props.append('password') + self._fetch_properties(*required_props) + + resp = {} + if 'auth-int' in self.qops: + self.qop = b'auth-int' + resp['qop'] = self.qop + + if getattr(self, 'realm', None) is not None: + resp['realm'] = quote(self.realm) + + resp['username'] = quote(bytes(self.username)) + resp['nonce'] = quote(self.nonce) + if self.nc == 0: + self.cnonce = bytes('%s' % random.random())[2:] + resp['cnonce'] = quote(self.cnonce) + self.nc += 1 + resp['nc'] = bytes('%08x' % self.nc) + + service = bytes(self.sasl.service) + host = bytes(self.sasl.host) + self._digest_uri = service + b'/' + host + resp['digest-uri'] = quote(self._digest_uri) + + a2 = b'AUTHENTICATE:' + self._digest_uri + if self.qop != b'auth': + a2 += b':00000000000000000000000000000000' + resp['maxbuf'] = b'16777215' # 2**24-1 + resp['response'] = self.gen_hash(a2) + return b','.join([bytes(k) + b'=' + bytes(v) for k, v in resp.items()]) + + def parse_challenge(self, challenge): + """ + """ + ret = {} + var = b'' + val = b'' + in_var = True + in_quotes = False + new = False + escaped = False + for c in challenge: + if sys.version_info >= (3, 0): + c = bytes([c]) + if in_var: + if c.isspace(): + continue + if c == b'=': + in_var = False + new = True + else: + var += c + else: + if new: + if c == b'"': + in_quotes = True + else: + val += c + new = False + elif in_quotes: + if escaped: + escaped = False + val += c + else: + if c == b'\\': + escaped = True + elif c == b'"': + in_quotes = False + else: + val += c + else: + if c == b',': + if var: + ret[var] = val + var = b'' + val = b'' + in_var = True + else: + val += c + if var: + ret[var] = val + return ret + + def gen_hash(self, a2): + if not getattr(self, 'key_hash', None): + key_hash = hashlib.md5() + user = bytes(self.username) + password = bytes(self.password) + realm = bytes(self.realm) + kh = user + b':' + realm + b':' + password + key_hash.update(kh) + self.key_hash = key_hash.digest() + + a1 = hashlib.md5(self.key_hash) + a1h = b':' + self.nonce + b':' + self.cnonce + a1.update(a1h) + response = hashlib.md5() + self._a1 = a1.digest() + rv = bytes(a1.hexdigest().lower()) + rv += b':' + self.nonce + rv += b':' + bytes('%08x' % self.nc) + rv += b':' + self.cnonce + rv += b':' + self.qop + rv += b':' + bytes(hashlib.md5(a2).hexdigest().lower()) + response.update(rv) + return bytes(response.hexdigest().lower()) + + def authenticate_server(self, cmp_hash): + a2 = b':' + self._digest_uri + if self.qop != b'auth': + a2 += b':00000000000000000000000000000000' + if self.gen_hash(a2) == cmp_hash: + self._rspauth_okay = True + + def process(self, challenge=None): + if challenge is None: + needed = ['username', 'realm', 'nonce', 'key_hash', + 'nc', 'cnonce', 'qops'] + if all(getattr(self, p, None) is not None for p in needed): + return self.response() + else: + return None + + challenge_dict = self.parse_challenge(challenge) + if self.sasl.mutual_auth and b'rspauth' in challenge_dict: + self.authenticate_server(challenge_dict[b'rspauth']) + else: + if b'realm' not in challenge_dict: + self._fetch_properties('realm') + challenge_dict[b'realm'] = self.realm + + for key in (b'nonce', b'realm'): + if key in challenge_dict: + setattr(self, key, challenge_dict[key]) + + self.nc = 0 + if b'qop' in challenge_dict: + server_offered_qops = [x.strip() for x in challenge_dict[b'qop'].split(b',')] + else: + server_offered_qops = [b'auth'] + self._pick_qop(server_offered_qops) + + if b'maxbuf' in challenge_dict: + self.max_buffer = min( + self.sasl.max_buffer, int(challenge_dict[b'maxbuf'])) + + return self.response() + + def okay(self): + """ + """ + if not self.sasl.mutual_auth: + return True + + if self._rspauth_okay and self.qop == b'auth-int': + self._enc_key = hashlib.md5(self._a1 + self.enc_magic).digest() + self._dec_key = hashlib.md5(self._a1 + self.dec_magic).digest() + self.encoding = True + return self._rspauth_okay + + +class GSSAPIMechanism(Mechanism): + name = 'GSSAPI' + score = 100 + + allows_anonymous = False + uses_plaintext = False + active_safe = True + + def __init__(self, sasl, **props): + Mechanism.__init__(self, sasl) + self.user = None + self.complete = False + self._have_negotiated_details = False + _, self.context = kerberos.authGSSClientInit(self.sasl.service) + + def process(self, challenge=None): + if not self._have_negotiated_details: + kerberos.authGSSClientStep(self.context, '') + _negotiated_details = kerberos.authGSSClientResponse(self.context) + self._have_negotiated_details = True + return base64.b64decode(_negotiated_details) + + challenge = base64.b64encode(challenge) + if self.user is None: + ret = kerberos.authGSSClientStep(self.context, challenge) + if ret == kerberos.AUTH_GSS_COMPLETE: + self.user = kerberos.authGSSClientUserName(self.context) + return '' + else: + response = kerberos.authGSSClientResponse(self.context) + if response: + response = base64.b64decode(response) + else: + response = '' + return response + + ret = kerberos.authGSSClientUnwrap(self.context, challenge) + data = kerberos.authGSSClientResponse(self.context) + plaintext_data = base64.b64decode(data) + if len(plaintext_data) != 4: + raise SASLProtocolException("Bad response from server") # todo: better message + + layers_supported, = struct.unpack('B', plaintext_data[0]) + server_offered_qops = [] + if 0x01 & layers_supported: + server_offered_qops.append('auth') + if 0x02 & layers_supported: + server_offered_qops.append('auth-int') + if 0x04 & layers_supported: + server_offered_qops.append('auth-conf') + + self._pick_qop(server_offered_qops) + + max_length, = struct.unpack('!i', '\x00' + plaintext_data[1:]) + self.max_buffer = min(self.sasl.max_buffer, max_length) + + ret = kerberos.authGSSClientWrap(self.context, data, self.user) + response = kerberos.authGSSClientResponse(self.context) + self.complete = True + return base64.b64decode(response) + + def wrap(self, outgoing): + if self.qop != 'auth': + outgoing = base64.b64encode(outgoing) + kerberos.authGSSClientWrap(self.context, outgoing, self.user) + return base64.b64decode(kerberos.authGSSClientResponse, self.context) + else: + return outgoing + + def unwrap(self, incoming): + if self.qop != 'auth': + incoming = base64.b64encode(incoming) + kerberos.authGSSClientUnwrap(self.context, incoming) + return base64.b64decode(kerberos.authGSSClientResponse, self.context) + else: + return incoming + + def okay(self): + return self.complete + + def dispose(self): + kerberos.authGSSClientClean(self.context) + + +#: Global registry mapping mechanism names to implementation classes. +mechanisms = dict((m.name, m) for m in ( + AnonymousMechanism, + PlainMechanism, + CramMD5Mechanism, + DigestMD5Mechanism)) + +if _have_kerberos: + mechanisms[GSSAPIMechanism.name] = GSSAPIMechanism diff --git a/puresasl/sasl.py b/puresasl/sasl.py new file mode 100644 index 0000000..4df8158 --- /dev/null +++ b/puresasl/sasl.py @@ -0,0 +1,85 @@ +import puresasl.mechanism as mech_mod +from puresasl import SASLError + + +def _require_mech(f): + def wrapped_f(self, *args, **kwargs): + if not self._chosen_mech: + raise SASLError("A mechanism has not been chosen yet") + return f(self, *args, **kwargs) + + wrapped_f.__name__ = f.__name__ + return wrapped_f + + +class SASLClient(object): + + def __init__(self, host, service, mechanism=None, authorization_id=None, + callback=None, qops=(b'auth',), mutual_auth=False, max_buffer=65536, + **mechanism_props): + self.host = host + self.service = service + self.authorization_id = authorization_id + self.mechanism = mechanism + self.callback = callback + self.qops = qops + self.mutual_auth = mutual_auth + self.max_buffer = max_buffer + + self._mech_props = mechanism_props + if self.mechanism is not None: + mech_class = mech_mod.mechanisms[mechanism] + self._chosen_mech = mech_class(self, **self._mech_props) + else: + self._chosen_mech = None + + @_require_mech + def process(self, challenge=None): + return self._chosen_mech.process(challenge) + + @_require_mech + def wrap(self, outgoing): + return self._chosen_mech.wrap(outgoing) + + @_require_mech + def unwrap(self, incoming): + return self._chosen_mech.unwrap(incoming) + + @property + def complete(self): + """ Has negotiation completed successfully? """ + if not self._chosen_mech: + raise SASLError("A mechanism has not been chosen yet") + return self._chosen_mech.complete + + @_require_mech + def dispose(self): + """ Clear all sensitive data """ + self._chosen_mech.dispose() + + def choose_mechanism(self, mechanism_choices, allow_anonymous=True, + allow_plaintext=True, allow_active=True, allow_dictionary=True): + """ + Choose the most secure mechanism from a list of mechanisms. + """ + candidates = [mech_mod.mechanisms[choice] + for choice in mechanism_choices + if choice in mech_mod.mechanisms] + + if not allow_anonymous: + candidates = [m for m in candidates if not m.allows_anonymous] + if not allow_plaintext: + candidates = [m for m in candidates if not m.uses_plaintext] + if not allow_active: + candidates = [m for m in candidates if m.active_safe] + if not allow_dictionary: + candidates = [m for m in candidates if m.allow_dictionary] + + if not candidates: + raise SASLError("None of the mechanisms listed meet all " + "required properties") + + # Pick the best mechanism based on its security score + mech_class = max(candidates, key=lambda mech: mech.score) + self.mechanism = mech_class.name + self._chosen_mech = mech_class(self, **self._mech_props) diff --git a/puresasl/util.py b/puresasl/util.py new file mode 100644 index 0000000..8b8b0bc --- /dev/null +++ b/puresasl/util.py @@ -0,0 +1,115 @@ +import sys +import hashlib + + +def bytes(text): + """ + Convert Unicode text to UTF-8 encoded bytes. + + Since Python 2.6+ and Python 3+ have similar but incompatible + signatures, this function unifies the two to keep code sane. + + :param text: Unicode text to convert to bytes + :rtype: bytes (Python3), str (Python2.6+) + """ + if sys.version_info < (3, 0): + import __builtin__ + return __builtin__.bytes(text) + else: + import builtins + if isinstance(text, builtins.bytes): + # We already have bytes, so do nothing + return text + if isinstance(text, list): + # Convert a list of integers to bytes + return builtins.bytes(text) + else: + # Convert UTF-8 text to bytes + return builtins.bytes(text, encoding='utf-8') + + +def quote(text): + """ + Enclose in quotes and escape internal slashes and double quotes. + + :param text: A Unicode or byte string. + """ + text = bytes(text) + return b'"' + text.replace(b'\\', b'\\\\').replace(b'"', b'\\"') + b'"' + + +def num_to_bytes(num): + """ + Convert an integer into a four byte sequence. + + :param integer num: An integer to convert to its byte representation. + """ + bval = b'' + bval += bytes(chr(0xFF & (num >> 24))) + bval += bytes(chr(0xFF & (num >> 16))) + bval += bytes(chr(0xFF & (num >> 8))) + bval += bytes(chr(0xFF & (num >> 0))) + return bval + + +def bytes_to_num(bval): + """ + Convert a four byte sequence to an integer. + + :param bytes bval: A four byte sequence to turn into an integer. + """ + num = 0 + num += ord(bval[0] << 24) + num += ord(bval[1] << 16) + num += ord(bval[2] << 8) + num += ord(bval[3]) + return num + + +def XOR(x, y): + """ + Return the results of an XOR operation on two equal length byte strings. + + :param bytes x: A byte string + :param bytes y: A byte string + :rtype: bytes + """ + result = b'' + for a, b in zip(x, y): + if sys.version_info < (3, 0): + result += chr((ord(a) ^ ord(b))) + else: + result += bytes([a ^ b]) + return result + + +def hash(name): + """ + Return a hash function implementing the given algorithm. + + :param name: The name of the hashing algorithm to use. + :type name: string + + :rtype: function + """ + name = name.lower() + if name.startswith('sha-'): + name = 'sha' + name[4:] + if name in dir(hashlib): + return getattr(hashlib, name) + return None + + +def hashes(): + """ + Return a list of available hashing algorithms. + + :rtype: list of strings + """ + t = [] + if 'md5' in dir(hashlib): + t = ['MD5'] + if 'md2' in dir(hashlib): + t += ['MD2'] + hashes = ['SHA-' + h[3:] for h in dir(hashlib) if h.startswith('sha')] + return t + hashes diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a828426 --- /dev/null +++ b/setup.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os + +try: + import subprocess + has_subprocess = True +except: + has_subprocess = False + +try: + from ez_setup import use_setuptools + use_setuptools() +except ImportError: + pass + +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + +from distutils.cmd import Command + +import puresasl + +class doc(Command): + + description = "generate or test documentation" + + user_options = [("test", "t", + "run doctests instead of generating documentation")] + + boolean_options = ["test"] + + def initialize_options(self): + self.test = False + + def finalize_options(self): + pass + + def run(self): + if self.test: + path = "doc/_build/doctest" + mode = "doctest" + else: + path = "doc/_build/%s" % puresasl.__version__ + mode = "html" + + try: + os.makedirs(path) + except: + pass + + if has_subprocess: + status = subprocess.call(["sphinx-build", "-b", mode, "doc", path]) + + if status: + raise RuntimeError("documentation step '%s' failed" % mode) + + print "" + print "Documentation step '%s' performed, results here:" % mode + print " %s/" % path + else: + print """ +`setup.py doc` is not supported for this version of Python. + +Please ask in the user forums for help. +""" + + +setup( + name='PureSASL', + version=puresasl.__version__, + author='Tyler Hobbs', + author_email='tylerlhobbs@gmail.com', + description='Pure Python client SASL implementation', + url='http://github.com/thobbs/puresasl', + keywords='sasl', + packages=['puresasl'], + cmdclass={"doc": doc}, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Software Development :: Libraries :: Python Modules' + ] +)