diff options
Diffstat (limited to 'sleekxmpp/thirdparty')
19 files changed, 2031 insertions, 0 deletions
diff --git a/sleekxmpp/thirdparty/__init__.py b/sleekxmpp/thirdparty/__init__.py new file mode 100644 index 00000000..1c7bf651 --- /dev/null +++ b/sleekxmpp/thirdparty/__init__.py @@ -0,0 +1,7 @@ +try: + from collections import OrderedDict +except: + from sleekxmpp.thirdparty.ordereddict import OrderedDict + +from sleekxmpp.thirdparty import suelta +from sleekxmpp.thirdparty.mini_dateutil import tzutc, tzoffset, parse_iso diff --git a/sleekxmpp/thirdparty/mini_dateutil.py b/sleekxmpp/thirdparty/mini_dateutil.py new file mode 100644 index 00000000..6af5ffde --- /dev/null +++ b/sleekxmpp/thirdparty/mini_dateutil.py @@ -0,0 +1,267 @@ +# This module is a very stripped down version of the dateutil +# package for when dateutil has not been installed. As a replacement +# for dateutil.parser.parse, the parsing methods from +# http://blog.mfabrik.com/2008/06/30/relativity-of-time-shortcomings-in-python-datetime-and-workaround/ + +#As such, the following copyrights and licenses applies: + + +# dateutil - Extensions to the standard python 2.3+ datetime module. +# +# Copyright (c) 2003-2011 - Gustavo Niemeyer <gustavo@niemeyer.net> +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# fixed_dateime +# +# Copyright (c) 2008, Red Innovation Ltd., Finland +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Red Innovation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY RED INNOVATION ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL RED INNOVATION BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + +import re +import datetime + + +ZERO = datetime.timedelta(0) + + +try: + from dateutil.parser import parse as parse_iso + from dateutil.tz import tzoffset, tzutc +except: + # As a stopgap, define the two timezones here based + # on the dateutil code. + + class tzutc(datetime.tzinfo): + + def utcoffset(self, dt): + return ZERO + + def dst(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def __eq__(self, other): + return (isinstance(other, tzutc) or + (isinstance(other, tzoffset) and other._offset == ZERO)) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s()" % self.__class__.__name__ + + __reduce__ = object.__reduce__ + + class tzoffset(datetime.tzinfo): + + def __init__(self, name, offset): + self._name = name + self._offset = datetime.timedelta(seconds=offset) + + def utcoffset(self, dt): + return self._offset + + def dst(self, dt): + return ZERO + + def tzname(self, dt): + return self._name + + def __eq__(self, other): + return (isinstance(other, tzoffset) and + self._offset == other._offset) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s(%s, %s)" % (self.__class__.__name__, + repr(self._name), + self._offset.days*86400+self._offset.seconds) + + __reduce__ = object.__reduce__ + + + _fixed_offset_tzs = { } + UTC = tzutc() + + def _get_fixed_offset_tz(offsetmins): + """For internal use only: Returns a tzinfo with + the given fixed offset. This creates only one instance + for each offset; the zones are kept in a dictionary""" + + if offsetmins == 0: + return UTC + + if not offsetmins in _fixed_offset_tzs: + if offsetmins < 0: + sign = '-' + absoff = -offsetmins + else: + sign = '+' + absoff = offsetmins + + name = "UTC%s%02d:%02d" % (sign, int(absoff / 60), absoff % 60) + inst = tzoffset(offsetmins, name) + _fixed_offset_tzs[offsetmins] = inst + + return _fixed_offset_tzs[offsetmins] + + + _iso8601_parser = re.compile(""" + ^ + (?P<year> [0-9]{4})?(?P<ymdsep>-?)? + (?P<month>[0-9]{2})?(?P=ymdsep)? + (?P<day> [0-9]{2})? + + (?: # time part... optional... at least hour must be specified + (?:T|\s+)? + (?P<hour>[0-9]{2}) + (?: + # minutes, separated with :, or none, from hours + (?P<hmssep>[:]?) + (?P<minute>[0-9]{2}) + (?: + # same for seconds, separated with :, or none, from hours + (?P=hmssep) + (?P<second>[0-9]{2}) + )? + )? + + # fractions + (?: [,.] (?P<frac>[0-9]{1,10}))? + + # timezone, Z, +-hh or +-hh:?mm. MUST BE, but complain if not there. + ( + (?P<tzempty>Z) + | + (?P<tzh>[+-][0-9]{2}) + (?: :? # optional separator + (?P<tzm>[0-9]{2}) + )? + )? + )? + $ + """, re.X) # """ + + def parse_iso(timestamp): + """Internal function for parsing a timestamp in + ISO 8601 format""" + + timestamp = timestamp.strip() + + m = _iso8601_parser.match(timestamp) + if not m: + raise ValueError("Not a proper ISO 8601 timestamp!: %s" % timestamp) + + vals = m.groupdict() + def_vals = {'year': 1970, 'month': 1, 'day': 1} + for key in vals: + if vals[key] is None: + vals[key] = def_vals.get(key, 0) + elif key not in ['ymdsep', 'hmssep', 'tzempty']: + vals[key] = int(vals[key]) + + year = vals['year'] + month = vals['month'] + day = vals['day'] + + h, min, s, us = None, None, None, 0 + frac = 0 + if m.group('tzempty') == None and m.group('tzh') == None: + raise ValueError("Not a proper ISO 8601 timestamp: " + + "missing timezone (Z or +hh[:mm])!") + + if m.group('frac'): + frac = m.group('frac') + power = len(frac) + frac = int(frac) / 10.0 ** power + + if m.group('hour'): + h = vals['hour'] + + if m.group('minute'): + min = vals['minute'] + + if m.group('second'): + s = vals['second'] + + if frac != None: + # ok, fractions of hour? + if min == None: + frac, min = _math.modf(frac * 60.0) + min = int(min) + + # fractions of second? + if s == None: + frac, s = _math.modf(frac * 60.0) + s = int(s) + + # and extract microseconds... + us = int(frac * 1000000) + + if m.group('tzempty') == 'Z': + offsetmins = 0 + else: + # timezone: hour diff with sign + offsetmins = vals['tzh'] * 60 + tzm = m.group('tzm') + + # add optional minutes + if tzm != None: + tzm = int(tzm) + offsetmins += tzm if offsetmins > 0 else -tzm + + tz = _get_fixed_offset_tz(offsetmins) + return datetime.datetime(year, month, day, h, min, s, us, tz) diff --git a/sleekxmpp/thirdparty/ordereddict.py b/sleekxmpp/thirdparty/ordereddict.py new file mode 100644 index 00000000..5b0303f5 --- /dev/null +++ b/sleekxmpp/thirdparty/ordereddict.py @@ -0,0 +1,127 @@ +# Copyright (c) 2009 Raymond Hettinger
+#
+# 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 UserDict import DictMixin
+
+class OrderedDict(dict, DictMixin):
+
+ def __init__(self, *args, **kwds):
+ if len(args) > 1:
+ raise TypeError('expected at most 1 arguments, got %d' % len(args))
+ try:
+ self.__end
+ except AttributeError:
+ self.clear()
+ self.update(*args, **kwds)
+
+ def clear(self):
+ self.__end = end = []
+ end += [None, end, end] # sentinel node for doubly linked list
+ self.__map = {} # key --> [key, prev, next]
+ dict.clear(self)
+
+ def __setitem__(self, key, value):
+ if key not in self:
+ end = self.__end
+ curr = end[1]
+ curr[2] = end[1] = self.__map[key] = [key, curr, end]
+ dict.__setitem__(self, key, value)
+
+ def __delitem__(self, key):
+ dict.__delitem__(self, key)
+ key, prev, next = self.__map.pop(key)
+ prev[2] = next
+ next[1] = prev
+
+ def __iter__(self):
+ end = self.__end
+ curr = end[2]
+ while curr is not end:
+ yield curr[0]
+ curr = curr[2]
+
+ def __reversed__(self):
+ end = self.__end
+ curr = end[1]
+ while curr is not end:
+ yield curr[0]
+ curr = curr[1]
+
+ def popitem(self, last=True):
+ if not self:
+ raise KeyError('dictionary is empty')
+ if last:
+ key = reversed(self).next()
+ else:
+ key = iter(self).next()
+ value = self.pop(key)
+ return key, value
+
+ def __reduce__(self):
+ items = [[k, self[k]] for k in self]
+ tmp = self.__map, self.__end
+ del self.__map, self.__end
+ inst_dict = vars(self).copy()
+ self.__map, self.__end = tmp
+ if inst_dict:
+ return (self.__class__, (items,), inst_dict)
+ return self.__class__, (items,)
+
+ def keys(self):
+ return list(self)
+
+ setdefault = DictMixin.setdefault
+ update = DictMixin.update
+ pop = DictMixin.pop
+ values = DictMixin.values
+ items = DictMixin.items
+ iterkeys = DictMixin.iterkeys
+ itervalues = DictMixin.itervalues
+ iteritems = DictMixin.iteritems
+
+ def __repr__(self):
+ if not self:
+ return '%s()' % (self.__class__.__name__,)
+ return '%s(%r)' % (self.__class__.__name__, self.items())
+
+ def copy(self):
+ return self.__class__(self)
+
+ @classmethod
+ def fromkeys(cls, iterable, value=None):
+ d = cls()
+ for key in iterable:
+ d[key] = value
+ return d
+
+ def __eq__(self, other):
+ if isinstance(other, OrderedDict):
+ if len(self) != len(other):
+ return False
+ for p, q in zip(self.items(), other.items()):
+ if p != q:
+ return False
+ return True
+ return dict.__eq__(self, other)
+
+ def __ne__(self, other):
+ return not self == other
diff --git a/sleekxmpp/thirdparty/statemachine.py b/sleekxmpp/thirdparty/statemachine.py new file mode 100644 index 00000000..8a7324b5 --- /dev/null +++ b/sleekxmpp/thirdparty/statemachine.py @@ -0,0 +1,287 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +import threading +import time +import logging + +log = logging.getLogger(__name__) + + +class StateMachine(object): + + def __init__(self, states=[]): + self.lock = threading.Lock() + self.notifier = threading.Event() + self.__states = [] + self.addStates(states) + self.__default_state = self.__states[0] + self.__current_state = self.__default_state + + def addStates(self, states): + self.lock.acquire() + try: + for state in states: + if state in self.__states: + raise IndexError("The state '%s' is already in the StateMachine." % state) + self.__states.append(state) + finally: self.lock.release() + + + def transition(self, from_state, to_state, wait=0.0, func=None, args=[], kwargs={}): + ''' + Transition from the given `from_state` to the given `to_state`. + This method will return `True` if the state machine is now in `to_state`. It + will return `False` if a timeout occurred the transition did not occur. + If `wait` is 0 (the default,) this method returns immediately if the state machine + is not in `from_state`. + + If you want the thread to block and transition once the state machine to enters + `from_state`, set `wait` to a non-negative value. Note there is no 'block + indefinitely' flag since this leads to deadlock. If you want to wait indefinitely, + choose a reasonable value for `wait` (e.g. 20 seconds) and do so in a while loop like so: + + :: + + while not thread_should_exit and not state_machine.transition('disconnected', 'connecting', wait=20 ): + pass # timeout will occur every 20s unless transition occurs + if thread_should_exit: return + # perform actions here after successful transition + + This allows the thread to be responsive by setting `thread_should_exit=True`. + + The optional `func` argument allows the user to pass a callable operation which occurs + within the context of the state transition (e.g. while the state machine is locked.) + If `func` returns a True value, the transition will occur. If `func` returns a non- + True value or if an exception is thrown, the transition will not occur. Any thrown + exception is not caught by the state machine and is the caller's responsibility to handle. + If `func` completes normally, this method will return the value returned by `func.` If + values for `args` and `kwargs` are provided, they are expanded and passed like so: + `func( *args, **kwargs )`. + ''' + + return self.transition_any((from_state,), to_state, wait=wait, + func=func, args=args, kwargs=kwargs) + + + def transition_any(self, from_states, to_state, wait=0.0, func=None, args=[], kwargs={}): + ''' + Transition from any of the given `from_states` to the given `to_state`. + ''' + + if not (isinstance(from_states,tuple) or isinstance(from_states,list)): + raise ValueError("from_states should be a list or tuple") + + for state in from_states: + if not state in self.__states: + raise ValueError("StateMachine does not contain from_state %s." % state) + if not to_state in self.__states: + raise ValueError("StateMachine does not contain to_state %s." % to_state) + + start = time.time() + while not self.lock.acquire(False): + time.sleep(.001) + if (start + wait - time.time()) <= 0.0: + log.debug("Could not acquire lock") + return False + + while not self.__current_state in from_states: + # detect timeout: + remainder = start + wait - time.time() + if remainder > 0: + self.notifier.wait(remainder) + else: + log.debug("State was not ready") + self.lock.release() + return False + + try: # lock is acquired; all other threads will return false or wait until notify/timeout + if self.__current_state in from_states: # should always be True due to lock + + # Note that func might throw an exception, but that's OK, it aborts the transition + return_val = func(*args,**kwargs) if func is not None else True + + # some 'false' value returned from func, + # indicating that transition should not occur: + if not return_val: return return_val + + log.debug(' ==== TRANSITION %s -> %s', self.__current_state, to_state) + self._set_state(to_state) + return return_val # some 'true' value returned by func or True if func was None + else: + log.error("StateMachine bug!! The lock should ensure this doesn't happen!") + return False + finally: + self.notifier.set() # notify any waiting threads that the state has changed. + self.notifier.clear() + self.lock.release() + + + def transition_ctx(self, from_state, to_state, wait=0.0): + ''' + Use the state machine as a context manager. The transition occurs on /exit/ from + the `with` context, so long as no exception is thrown. For example: + + :: + + with state_machine.transition_ctx('one','two', wait=5) as locked: + if locked: + # the state machine is currently locked in state 'one', and will + # transition to 'two' when the 'with' statement ends, so long as + # no exception is thrown. + print 'Currently locked in state one: %s' % state_machine['one'] + + else: + # The 'wait' timed out, and no lock has been acquired + print 'Timed out before entering state "one"' + + print 'Since no exception was thrown, we are now in state "two": %s' % state_machine['two'] + + + The other main difference between this method and `transition()` is that the + state machine is locked for the duration of the `with` statement. Normally, + after a `transition()` occurs, the state machine is immediately unlocked and + available to another thread to call `transition()` again. + ''' + + if not from_state in self.__states: + raise ValueError("StateMachine does not contain from_state %s." % from_state) + if not to_state in self.__states: + raise ValueError("StateMachine does not contain to_state %s." % to_state) + + return _StateCtx(self, from_state, to_state, wait) + + + def ensure(self, state, wait=0.0, block_on_transition=False): + ''' + Ensure the state machine is currently in `state`, or wait until it enters `state`. + ''' + return self.ensure_any((state,), wait=wait, block_on_transition=block_on_transition) + + + def ensure_any(self, states, wait=0.0, block_on_transition=False): + ''' + Ensure we are currently in one of the given `states` or wait until + we enter one of those states. + + Note that due to the nature of the function, you cannot guarantee that + the entirety of some operation completes while you remain in a given + state. That would require acquiring and holding a lock, which + would mean no other threads could do the same. (You'd essentially + be serializing all of the threads that are 'ensuring' their tasks + occurred in some state. + ''' + if not (isinstance(states,tuple) or isinstance(states,list)): + raise ValueError('states arg should be a tuple or list') + + for state in states: + if not state in self.__states: + raise ValueError("StateMachine does not contain state '%s'" % state) + + # if we're in the middle of a transition, determine whether we should + # 'fall back' to the 'current' state, or wait for the new state, in order to + # avoid an operation occurring in the wrong state. + # TODO another option would be an ensure_ctx that uses a semaphore to allow + # threads to indicate they want to remain in a particular state. + + # will return immediately if no transition is in process. + if block_on_transition: + # we're not in the middle of a transition; don't hold the lock + if self.lock.acquire(False): self.lock.release() + # wait for the transition to complete + else: self.notifier.wait() + + start = time.time() + while not self.__current_state in states: + # detect timeout: + remainder = start + wait - time.time() + if remainder > 0: self.notifier.wait(remainder) + else: return False + return True + + + def reset(self): + # TODO need to lock before calling this? + self.transition(self.__current_state, self.__default_state) + + + def _set_state(self, state): #unsynchronized, only call internally after lock is acquired + self.__current_state = state + return state + + + def current_state(self): + ''' + Return the current state name. + ''' + return self.__current_state + + + def __getitem__(self, state): + ''' + Non-blocking, non-synchronized test to determine if we are in the given state. + Use `StateMachine.ensure(state)` to wait until the machine enters a certain state. + ''' + return self.__current_state == state + + def __str__(self): + return "".join(("StateMachine(", ','.join(self.__states), "): ", self.__current_state)) + + + +class _StateCtx: + + def __init__(self, state_machine, from_state, to_state, wait): + self.state_machine = state_machine + self.from_state = from_state + self.to_state = to_state + self.wait = wait + self._locked = False + + def __enter__(self): + start = time.time() + while not self.state_machine[self.from_state] or not self.state_machine.lock.acquire(False): + # detect timeout: + remainder = start + self.wait - time.time() + if remainder > 0: self.state_machine.notifier.wait(remainder) + else: + log.debug('StateMachine timeout while waiting for state: %s', self.from_state) + return False + + self._locked = True # lock has been acquired at this point + self.state_machine.notifier.clear() + log.debug('StateMachine entered context in state: %s', + self.state_machine.current_state()) + return True + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is not None: + log.exception("StateMachine exception in context, remaining in state: %s\n%s:%s", + self.state_machine.current_state(), exc_type.__name__, exc_val) + + if self._locked: + if exc_val is None: + log.debug(' ==== TRANSITION %s -> %s', + self.state_machine.current_state(), self.to_state) + self.state_machine._set_state(self.to_state) + + self.state_machine.notifier.set() + self.state_machine.lock.release() + + return False # re-raise any exception + +if __name__ == '__main__': + + def callback(s, s2): + print((1, s.transition('on', 'off', wait=0.0, func=callback, args=[s,s2]))) + print((2, s2.transition('off', 'on', func=callback, args=[s,s2]))) + return True + + s = StateMachine(('off', 'on')) + s2 = StateMachine(('off', 'on')) + print((3, s.transition('off', 'on', wait=0.0, func=callback, args=[s,s2]),)) + print((s.current_state(), s2.current_state())) diff --git a/sleekxmpp/thirdparty/suelta/LICENSE b/sleekxmpp/thirdparty/suelta/LICENSE new file mode 100644 index 00000000..6eee4f33 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/LICENSE @@ -0,0 +1,21 @@ +This software is subject to "The MIT License" + +Copyright 2007-2010 David Alan Cridland + +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/sleekxmpp/thirdparty/suelta/PLAYING-NICELY b/sleekxmpp/thirdparty/suelta/PLAYING-NICELY new file mode 100644 index 00000000..393b8078 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/PLAYING-NICELY @@ -0,0 +1,27 @@ +Hi. + +This is a short note explaining the license in non-legally-binding terms, and +describing how I hope to see people work with the licensing. + +First off, the license is permissive, and more or less allows you to do +anything, as long as you leave my credit and copyright intact. + +You can, and are very much welcome to, include this in commercial works, and +in code that has tightly controlled distribution, as well as open-source. + +If it doesn't work - and I have no doubt that there are bugs - then this is +largely your problem. + +If you do find a bug, though, do let me know - although you don't have to. + +And if you fix it, I'd greatly appreciate a patch, too. Please give me a +licensing statement, and a copyright statement, along with your patch. + +Similarly, any enhancements are welcome, and also will need copyright and +licensing. Please stick to a license which is compatible with the MIT license, +and consider assignment (as required) to me to simplify licensing. (Public +domain does not exist in the UK, sorry). + +Thanks, + +Dave. diff --git a/sleekxmpp/thirdparty/suelta/README b/sleekxmpp/thirdparty/suelta/README new file mode 100644 index 00000000..c32463a4 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/README @@ -0,0 +1,8 @@ +Suelta - A pure-Python SASL client library + +Suelta is a SASL library, providing you with authentication and in some cases +security layers. + +It supports a wide range of typical SASL mechanisms, including the MTI for +all known protocols. + diff --git a/sleekxmpp/thirdparty/suelta/__init__.py b/sleekxmpp/thirdparty/suelta/__init__.py new file mode 100644 index 00000000..04f0cbad --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/__init__.py @@ -0,0 +1,26 @@ +# Copyright 2007-2010 David Alan Cridland +# +# 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 sleekxmpp.thirdparty.suelta.saslprep import saslprep +from sleekxmpp.thirdparty.suelta.sasl import * +from sleekxmpp.thirdparty.suelta.mechanisms import * + +__version__ = '2.0' +__version_info__ = (2, 0, 0) diff --git a/sleekxmpp/thirdparty/suelta/exceptions.py b/sleekxmpp/thirdparty/suelta/exceptions.py new file mode 100644 index 00000000..625cca0e --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/exceptions.py @@ -0,0 +1,31 @@ +class SASLError(Exception): + + def __init__(self, sasl, text, mech=None): + """ + :param sasl: The main `suelta.SASL` object. + :param text: Descpription of the error. + :param mech: Optional reference to the mechanism object. + + :type sasl: `suelta.SASL` + """ + self.sasl = sasl + self.text = text + self.mech = mech + + def __str__(self): + if self.mech is None: + return 'SASL Error: %s' % self.text + else: + return 'SASL Error (%s): %s' % (self.mech, self.text) + + +class SASLCancelled(SASLError): + + def __init__(self, sasl, mech=None): + """ + :param sasl: The main `suelta.SASL` object. + :param mech: Optional reference to the mechanism object. + + :type sasl: `suelta.SASL` + """ + super(SASLCancelled, self).__init__(sasl, "User cancelled", mech) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/__init__.py b/sleekxmpp/thirdparty/suelta/mechanisms/__init__.py new file mode 100644 index 00000000..e115e5d5 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/__init__.py @@ -0,0 +1,6 @@ +from sleekxmpp.thirdparty.suelta.mechanisms.anonymous import ANONYMOUS +from sleekxmpp.thirdparty.suelta.mechanisms.plain import PLAIN +from sleekxmpp.thirdparty.suelta.mechanisms.cram_md5 import CRAM_MD5 +from sleekxmpp.thirdparty.suelta.mechanisms.digest_md5 import DIGEST_MD5 +from sleekxmpp.thirdparty.suelta.mechanisms.scram_hmac import SCRAM_HMAC +from sleekxmpp.thirdparty.suelta.mechanisms.messenger_oauth2 import X_MESSENGER_OAUTH2 diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/anonymous.py b/sleekxmpp/thirdparty/suelta/mechanisms/anonymous.py new file mode 100644 index 00000000..e44e91a2 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/anonymous.py @@ -0,0 +1,36 @@ +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +class ANONYMOUS(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(ANONYMOUS, self).__init__(sasl, name, 0) + + def get_values(self): + """ + """ + return {} + + def process(self, challenge=None): + """ + """ + return b'Anonymous, Suelta' + + def okay(self): + """ + """ + return True + + def get_user(self): + """ + """ + return 'anonymous' + + +register_mechanism('ANONYMOUS', 0, ANONYMOUS, use_hashes=False) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/cram_md5.py b/sleekxmpp/thirdparty/suelta/mechanisms/cram_md5.py new file mode 100644 index 00000000..ba44befe --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/cram_md5.py @@ -0,0 +1,63 @@ +import sys +import hmac + +from sleekxmpp.thirdparty.suelta.util import hash, bytes +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +class CRAM_MD5(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(CRAM_MD5, self).__init__(sasl, name, 2) + + self.hash = hash(name[5:]) + if self.hash is None: + raise SASLCancelled(self.sasl, self) + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, 'CRAM-MD5'): + raise SASLCancelled(self.sasl, self) + + def prep(self): + """ + """ + if 'savepass' not in self.values: + if self.sasl.sec_query(self, 'CLEAR-PASSWORD'): + self.values['savepass'] = True + + if 'savepass' not in self.values: + del self.values['password'] + + def process(self, challenge): + """ + """ + if challenge is None: + return None + + self.check_values(['username', 'password']) + username = bytes(self.values['username']) + password = bytes(self.values['password']) + + mac = hmac.HMAC(key=password, digestmod=self.hash) + + mac.update(challenge) + + return username + b' ' + bytes(mac.hexdigest()) + + def okay(self): + """ + """ + return True + + def get_user(self): + """ + """ + return self.values['username'] + + +register_mechanism('CRAM-', 20, CRAM_MD5) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/digest_md5.py b/sleekxmpp/thirdparty/suelta/mechanisms/digest_md5.py new file mode 100644 index 00000000..5492c553 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/digest_md5.py @@ -0,0 +1,273 @@ +import sys + +import random + +from sleekxmpp.thirdparty.suelta.util import hash, bytes, quote +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + + +def parse_challenge(stuff): + """ + """ + ret = {} + var = b'' + val = b'' + in_var = True + in_quotes = False + new = False + escaped = False + for c in stuff: + 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 + + +class DIGEST_MD5(Mechanism): + + """ + """ + + 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, name): + """ + """ + super(DIGEST_MD5, self).__init__(sasl, name, 3) + + self.hash = hash(name[7:]) + if self.hash is None: + raise SASLCancelled(self.sasl, self) + + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, '-ENCRYPTION, DIGEST-MD5'): + raise SASLCancelled(self.sasl, self) + + self._rspauth_okay = False + self._digest_uri = None + self._a1 = None + self._enc_buf = b'' + self._enc_key = None + self._enc_seq = 0 + self._max_buffer = 65536 + self._dec_buf = b'' + self._dec_key = None + self._dec_seq = 0 + self._qops = [b'auth'] + self._qop = b'auth' + + def MAC(self, seq, msg, key): + """ + """ + mac = hmac.HMAC(key=key, digestmod=self.hash) + seqnum = num_to_bytes(seq) + mac.update(seqnum) + mac.update(msg) + return mac.digest()[:10] + b'\x00\x01' + seqnum + + + def encode(self, text): + """ + """ + self._enc_buf += text + + def flush(self): + """ + """ + result = b'' + # Leave buffer space for the MAC + mbuf = self._max_buffer - 10 - 2 - 4 + + while self._enc_buf: + msg = self._encbuf[:mbuf] + mac = self.MAC(self._enc_seq, msg, self._enc_key, self.hash) + self._enc_seq += 1 + msg += mac + result += num_to_bytes(len(msg)) + msg + self._enc_buf = self._enc_buf[mbuf:] + + return result + + def decode(self, text): + """ + """ + self._dec_buf += text + result = b'' + + while len(self._dec_buf) > 4: + num = bytes_to_num(self._dec_buf) + if len(self._dec_buf) < (num + 4): + return result + + mac = self._dec_buf[4:4 + num] + self._dec_buf = self._dec_buf[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): + """ + """ + vitals = ['username'] + if not self.has_values(['key_hash']): + vitals.append('password') + self.check_values(vitals) + + resp = {} + if 'auth-int' in self._qops: + self._qop = b'auth-int' + resp['qop'] = self._qop + if 'realm' in self.values: + resp['realm'] = quote(self.values['realm']) + + resp['username'] = quote(bytes(self.values['username'])) + resp['nonce'] = quote(self.values['nonce']) + if self.values['nc']: + self._cnonce = self.values['cnonce'] + else: + self._cnonce = bytes('%s' % random.random())[2:] + resp['cnonce'] = quote(self._cnonce) + self.values['nc'] += 1 + resp['nc'] = bytes('%08x' % self.values['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 gen_hash(self, a2): + """ + """ + if not self.has_values(['key_hash']): + key_hash = self.hash() + user = bytes(self.values['username']) + password = bytes(self.values['password']) + realm = bytes(self.values['realm']) + kh = user + b':' + realm + b':' + password + key_hash.update(kh) + self.values['key_hash'] = key_hash.digest() + + a1 = self.hash(self.values['key_hash']) + a1h = b':' + self.values['nonce'] + b':' + self._cnonce + a1.update(a1h) + response = self.hash() + self._a1 = a1.digest() + rv = bytes(a1.hexdigest().lower()) + rv += b':' + self.values['nonce'] + rv += b':' + bytes('%08x' % self.values['nc']) + rv += b':' + self._cnonce + rv += b':' + self._qop + rv += b':' + bytes(self.hash(a2).hexdigest().lower()) + response.update(rv) + return bytes(response.hexdigest().lower()) + + def mutual_auth(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 prep(self): + """ + """ + if 'password' in self.values: + del self.values['password'] + self.values['cnonce'] = self._cnonce + + def process(self, challenge=None): + """ + """ + if challenge is None: + if self.has_values(['username', 'realm', 'nonce', 'key_hash', + 'nc', 'cnonce', 'qops']): + self._qops = self.values['qops'] + return self.response() + else: + return None + + d = parse_challenge(challenge) + if b'rspauth' in d: + self.mutual_auth(d[b'rspauth']) + else: + if b'realm' not in d: + d[b'realm'] = self.sasl.def_realm + for key in ['nonce', 'realm']: + if bytes(key) in d: + self.values[key] = d[bytes(key)] + self.values['nc'] = 0 + self._qops = [b'auth'] + if b'qop' in d: + self._qops = [x.strip() for x in d[b'qop'].split(b',')] + self.values['qops'] = self._qops + if b'maxbuf' in d: + self._max_buffer = int(d[b'maxbuf']) + return self.response() + + def okay(self): + """ + """ + if self._rspauth_okay and self._qop == b'auth-int': + self._enc_key = self.hash(self._a1 + self.enc_magic).digest() + self._dec_key = self.hash(self._a1 + self.dec_magic).digest() + self.encoding = True + return self._rspauth_okay + + +register_mechanism('DIGEST-', 30, DIGEST_MD5) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/messenger_oauth2.py b/sleekxmpp/thirdparty/suelta/mechanisms/messenger_oauth2.py new file mode 100644 index 00000000..f5b0ddec --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/messenger_oauth2.py @@ -0,0 +1,17 @@ +from sleekxmpp.thirdparty.suelta.util import bytes +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism + + +class X_MESSENGER_OAUTH2(Mechanism): + + def __init__(self, sasl, name): + super(X_MESSENGER_OAUTH2, self).__init__(sasl, name) + self.check_values(['access_token']) + + def process(self, challenge=None): + return bytes(self.values['access_token']) + + def okay(self): + return True + +register_mechanism('X-MESSENGER-OAUTH2', 10, X_MESSENGER_OAUTH2, use_hashes=False) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/plain.py b/sleekxmpp/thirdparty/suelta/mechanisms/plain.py new file mode 100644 index 00000000..ab17095e --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/plain.py @@ -0,0 +1,61 @@ +import sys + +from sleekxmpp.thirdparty.suelta.util import bytes +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +class PLAIN(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(PLAIN, self).__init__(sasl, name) + + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, '-ENCRYPTION, PLAIN'): + raise SASLCancelled(self.sasl, self) + else: + if not self.sasl.sec_query(self, '+ENCRYPTION, PLAIN'): + raise SASLCancelled(self.sasl, self) + + self.check_values(['username', 'password']) + + def prep(self): + """ + Prepare for processing by deleting the password if + the user has not approved storing it in the clear. + """ + if 'savepass' not in self.values: + if self.sasl.sec_query(self, 'CLEAR-PASSWORD'): + self.values['savepass'] = True + + if 'savepass' not in self.values: + del self.values['password'] + + return True + + 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. + """ + user = bytes(self.values['username']) + password = bytes(self.values['password']) + return b'\x00' + user + b'\x00' + password + + def okay(self): + """ + Mutual authentication is not supported by PLAIN. + + :returns: ``True`` + """ + return True + + +register_mechanism('PLAIN', 1, PLAIN, use_hashes=False) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/scram_hmac.py b/sleekxmpp/thirdparty/suelta/mechanisms/scram_hmac.py new file mode 100644 index 00000000..b70ac9a4 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/scram_hmac.py @@ -0,0 +1,176 @@ +import sys +import hmac +import random +from base64 import b64encode, b64decode + +from sleekxmpp.thirdparty.suelta.util import hash, bytes, num_to_bytes, bytes_to_num, XOR +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +def parse_challenge(challenge): + """ + """ + items = {} + for key, value in [item.split(b'=', 1) for item in challenge.split(b',')]: + items[key] = value + return items + + +class SCRAM_HMAC(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(SCRAM_HMAC, self).__init__(sasl, name, 0) + + self._cb = False + if name[-5:] == '-PLUS': + name = name[:-5] + self._cb = True + + self.hash = hash(name[6:]) + if self.hash is None: + raise SASLCancelled(self.sasl, self) + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, '-ENCRYPTION, SCRAM'): + raise SASLCancelled(self.sasl, self) + + self._step = 0 + self._rspauth = False + + def HMAC(self, key, msg): + """ + """ + return hmac.HMAC(key=key, msg=msg, digestmod=self.hash).digest() + + def Hi(self, text, salt, iterations): + """ + """ + text = bytes(text) + ui_1 = self.HMAC(text, salt + b'\0\0\0\01') + ui = ui_1 + for i in range(iterations - 1): + ui_1 = self.HMAC(text, ui_1) + ui = XOR(ui, ui_1) + return ui + + def H(self, text): + """ + """ + return self.hash(text).digest() + + def prep(self): + if 'password' in self.values: + del self.values['password'] + + def process(self, challenge=None): + """ + """ + steps = { + 0: self.process_one, + 1: self.process_two, + 2: self.process_three + } + return steps[self._step](challenge) + + def process_one(self, challenge): + """ + """ + vitals = ['username'] + if 'SaltedPassword' not in self.values: + vitals.append('password') + if 'Iterations' not in self.values: + vitals.append('password') + + self.check_values(vitals) + + username = bytes(self.values['username']) + + self._step = 1 + self._cnonce = bytes(('%s' % random.random())[2:]) + self._soup = b'n=' + username + b',r=' + self._cnonce + self._gs2header = b'' + + if not self.sasl.tls_active(): + if self._cb: + self._gs2header = b'p=tls-unique,,' + else: + self._gs2header = b'y,,' + else: + self._gs2header = b'n,,' + + return self._gs2header + self._soup + + def process_two(self, challenge): + """ + """ + data = parse_challenge(challenge) + + self._step = 2 + self._soup += b',' + challenge + b',' + self._nonce = data[b'r'] + self._salt = b64decode(data[b's']) + self._iter = int(data[b'i']) + + if self._nonce[:len(self._cnonce)] != self._cnonce: + raise SASLCancelled(self.sasl, self) + + cbdata = self.sasl.tls_active() + c = self._gs2header + if not cbdata and self._cb: + c += None + + r = b'c=' + b64encode(c).replace(b'\n', b'') + r += b',r=' + self._nonce + self._soup += r + + if 'Iterations' in self.values: + if self.values['Iterations'] != self._iter: + if 'SaltedPassword' in self.values: + del self.values['SaltedPassword'] + if 'Salt' in self.values: + if self.values['Salt'] != self._salt: + if 'SaltedPassword' in self.values: + del self.values['SaltedPassword'] + + self.values['Iterations'] = self._iter + self.values['Salt'] = self._salt + + if 'SaltedPassword' not in self.values: + self.check_values(['password']) + password = bytes(self.values['password']) + salted_pass = self.Hi(password, self._salt, self._iter) + self.values['SaltedPassword'] = salted_pass + + salted_pass = self.values['SaltedPassword'] + client_key = self.HMAC(salted_pass, b'Client Key') + stored_key = self.H(client_key) + client_sig = self.HMAC(stored_key, self._soup) + client_proof = XOR(client_key, client_sig) + r += b',p=' + b64encode(client_proof).replace(b'\n', b'') + server_key = self.HMAC(self.values['SaltedPassword'], b'Server Key') + self.server_sig = self.HMAC(server_key, self._soup) + return r + + def process_three(self, challenge=None): + """ + """ + data = parse_challenge(challenge) + if b64decode(data[b'v']) == self.server_sig: + self._rspauth = True + + def okay(self): + """ + """ + return self._rspauth + + def get_user(self): + return self.values['username'] + + +register_mechanism('SCRAM-', 60, SCRAM_HMAC) +register_mechanism('SCRAM-', 70, SCRAM_HMAC, extra='-PLUS') diff --git a/sleekxmpp/thirdparty/suelta/sasl.py b/sleekxmpp/thirdparty/suelta/sasl.py new file mode 100644 index 00000000..2ae9ae61 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/sasl.py @@ -0,0 +1,402 @@ +from sleekxmpp.thirdparty.suelta.util import hashes +from sleekxmpp.thirdparty.suelta.saslprep import saslprep + +#: Global session storage for user answers to requested mechanism values +#: and security questions. This allows the user's preferences to be +#: persisted across multiple SASL authentication attempts made by the +#: same process. +SESSION = {'answers': {}, + 'passwords': {}, + 'sec_queries': {}, + 'stash': {}, + 'stash_file': ''} + +#: Global registry mapping mechanism names to implementation classes. +MECHANISMS = {} + +#: Global registry mapping mechanism names to security scores. +MECH_SEC_SCORES = {} + + +def register_mechanism(basename, basescore, impl, extra=None, use_hashes=True): + """ + Add a SASL mechanism to the registry of available mechanisms. + + :param basename: The base name of the mechanism type, such as ``CRAM-``. + :param basescore: The base security score for this type of mechanism. + :param impl: The class implementing the mechanism. + :param extra: Any additional qualifiers to the mechanism name, + such as ``-PLUS``. + :param use_hashes: If ``True``, then register the mechanism for use with + all available hashes. + """ + n = 0 + if use_hashes: + for hashing_alg in hashes(): + n += 1 + name = basename + hashing_alg + if extra is not None: + name += extra + MECHANISMS[name] = impl + MECH_SEC_SCORES[name] = basescore + n + else: + MECHANISMS[basename] = impl + MECH_SEC_SCORES[basename] = basescore + + +def set_stash_file(filename): + """ + Enable or disable storing the stash to disk. + + If the filename is ``None``, then disable using a stash file. + + :param filename: The path to the file to store the stash data. + """ + SESSION['stash_file'] = filename + try: + import marshal + stash_file = file(filename) + SESSION['stash'] = marshal.load(stash_file) + except: + SESSION['stash'] = {} + + +def sec_query_allow(mech, query): + """ + Quick default to allow all feature combinations which could + negatively affect security. + + :param mech: The chosen SASL mechanism + :param query: An encoding of the combination of enabled and + disabled features which may affect security. + + :returns: ``True`` + """ + return True + + +class SASL(object): + + """ + """ + + def __init__(self, host, service, mech=None, username=None, + min_sec=0, request_values=None, sec_query=None, + tls_active=None, def_realm=None): + """ + :param string host: The host of the service requiring authentication. + :param string service: The name of the underlying protocol in use. + :param string mech: Optional name of the SASL mechanism to use. + If given, only this mechanism may be used for + authentication. + :param string username: The username to use when authenticating. + :param request_values: Reference to a function for supplying + values requested by mechanisms, such + as passwords. (See above) + :param sec_query: Reference to a function for approving or + denying feature combinations which could + negatively impact security. (See above) + :param tls_active: Function for indicating if TLS has been + negotiated. (See above) + :param integer min_sec: The minimum security level accepted. This + only allows for SASL mechanisms whose + security rating is greater than `min_sec`. + :param string def_realm: The default realm, if different than `host`. + + :type request_values: :func:`request_values` + :type sec_query: :func:`sec_query` + :type tls_active: :func:`tls_active` + """ + self.host = host + self.def_realm = def_realm or host + self.service = service + self.user = username + self.mech = mech + self.min_sec = min_sec - 1 + + self.request_values = request_values + self._sec_query = sec_query + if tls_active is not None: + self.tls_active = tls_active + else: + self.tls_active = lambda: False + + self.try_username = self.user + self.try_password = None + + self.stash_id = None + self.testkey = None + + def reset_stash_id(self, username): + """ + Reset the ID for the stash for persisting user data. + + :param username: The username to base the new ID on. + """ + username = saslprep(username) + self.user = username + self.try_username = self.user + self.testkey = [self.user, self.host, self.service] + self.stash_id = '\0'.join(self.testkey) + + def sec_query(self, mech, query): + """ + Request authorization from the user to use a combination + of features which could negatively affect security. + + The ``sec_query`` callback when creating the SASL object will + be called if the query has not been answered before. Otherwise, + the query response will be pulled from ``SESSION['sec_queries']``. + + If no ``sec_query`` callback was provided, then all queries + will be denied. + + :param mech: The chosen SASL mechanism + :param query: An encoding of the combination of enabled and + disabled features which may affect security. + :rtype: bool + """ + if self._sec_query is None: + return False + if query in SESSION['sec_queries']: + return SESSION['sec_queries'][query] + resp = self._sec_query(mech, query) + if resp: + SESSION['sec_queries'][query] = resp + + return resp + + def find_password(self, mech): + """ + Find and return the user's password, if it has been entered before + during this session. + + :param mech: The chosen SASL mechanism. + """ + if self.try_password is not None: + return self.try_password + if self.testkey is None: + return + + testkey = self.testkey[:] + lockout = 1 + + def find_username(self): + """Find and return user's username if known.""" + return self.try_username + + def success(self, mech): + mech.preprep() + if 'password' in mech.values: + testkey = self.testkey[:] + while len(testkey): + tk = '\0'.join(testkey) + if tk in SESSION['passwords']: + break + SESSION['passwords'][tk] = mech.values['password'] + testkey = testkey[:-1] + mech.prep() + mech.save_values() + + def failure(self, mech): + mech.clear() + self.testkey = self.testkey[:-1] + + def choose_mechanism(self, mechs, force_plain=False): + """ + Choose the most secure mechanism from a list of mechanisms. + + If ``force_plain`` is given, return the ``PLAIN`` mechanism. + + :param mechs: A list of mechanism names. + :param force_plain: If ``True``, force the selection of the + ``PLAIN`` mechanism. + :returns: A SASL mechanism object, or ``None`` if no mechanism + could be selected. + """ + # Handle selection of PLAIN and ANONYMOUS + if force_plain: + return MECHANISMS['PLAIN'](self, 'PLAIN') + + if self.user is not None: + requested_mech = '*' if self.mech is None else self.mech + else: + if self.mech is None: + requested_mech = 'ANONYMOUS' + else: + requested_mech = self.mech + if requested_mech == '*' and self.user in ['', 'anonymous', None]: + requested_mech = 'ANONYMOUS' + + # If a specific mechanism was requested, try it + if requested_mech != '*': + if requested_mech in MECHANISMS and \ + requested_mech in MECH_SEC_SCORES: + return MECHANISMS[requested_mech](self, requested_mech) + return None + + # Pick the best mechanism based on its security score + best_score = self.min_sec + best_mech = None + for name in mechs: + if name in MECH_SEC_SCORES: + if MECH_SEC_SCORES[name] > best_score: + best_score = MECH_SEC_SCORES[name] + best_mech = name + if best_mech is not None: + best_mech = MECHANISMS[best_mech](self, best_mech) + + return best_mech + + +class Mechanism(object): + + """ + """ + + def __init__(self, sasl, name, version=0, use_stash=True): + self.name = name + self.sasl = sasl + self.use_stash = use_stash + + self.encoding = False + self.values = {} + + if use_stash: + self.load_values() + + def load_values(self): + """Retrieve user data from the stash.""" + self.values = {} + if not self.use_stash: + return False + if self.sasl.stash_id is not None: + if self.sasl.stash_id in SESSION['stash']: + if SESSION['stash'][self.sasl.stash_id]['mech'] == self.name: + values = SESSION['stash'][self.sasl.stash_id]['values'] + self.values.update(values) + if self.sasl.user is not None: + if not self.has_values(['username']): + self.values['username'] = self.sasl.user + return None + + def save_values(self): + """ + Save user data to the session stash. + + If a stash file name has been set using ``SESSION['stash_file']``, + the saved values will be persisted to disk. + """ + if not self.use_stash: + return False + if self.sasl.stash_id is not None: + if self.sasl.stash_id not in SESSION['stash']: + SESSION['stash'][self.sasl.stash_id] = {} + SESSION['stash'][self.sasl.stash_id]['values'] = self.values + SESSION['stash'][self.sasl.stash_id]['mech'] = self.name + if SESSION['stash_file'] not in ['', None]: + import marshal + stash_file = file(SESSION['stash_file'], 'wb') + marshal.dump(SESSION['stash'], stash_file) + + def clear(self): + """Reset all user data, except the username.""" + username = None + if 'username' in self.values: + username = self.values['username'] + self.values = {} + if username is not None: + self.values['username'] = username + self.save_values() + self.values = {} + self.load_values() + + def okay(self): + """ + Indicate if mutual authentication has completed successfully. + + :rtype: bool + """ + return False + + def preprep(self): + """Ensure that the stash ID has been set before processing.""" + if self.sasl.stash_id is None: + if 'username' in self.values: + self.sasl.reset_stash_id(self.values['username']) + + def prep(self): + """ + Prepare stored values for processing. + + For example, by removing extra copies of passwords from memory. + """ + pass + + 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 NotImplemented + + def fulfill(self, values): + """ + Provide requested values to the mechanism. + + :param values: A dictionary of requested values. + """ + if 'password' in values: + values['password'] = saslprep(values['password']) + self.values.update(values) + + def missing_values(self, keys): + """ + Return a dictionary of value names that have not been given values + by the user, or retrieved from the stash. + + :param keys: A list of value names to check. + :rtype: dict + """ + vals = {} + for name in keys: + if name not in self.values or self.values[name] is None: + if self.use_stash: + if name == 'username': + value = self.sasl.find_username() + if value is not None: + self.sasl.reset_stash_id(value) + self.values[name] = value + break + if name == 'password': + value = self.sasl.find_password(self) + if value is not None: + self.values[name] = value + break + vals[name] = None + return vals + + def has_values(self, keys): + """ + Check that the given values have been retrieved from the user, + or from the stash. + + :param keys: A list of value names to check. + """ + return len(self.missing_values(keys)) == 0 + + def check_values(self, keys): + """ + Request missing values from the user. + + :param keys: A list of value names to request, if missing. + """ + vals = self.missing_values(keys) + if vals: + self.sasl.request_values(self, vals) + + def get_user(self): + """Return the username usd for this mechanism.""" + return self.values['username'] diff --git a/sleekxmpp/thirdparty/suelta/saslprep.py b/sleekxmpp/thirdparty/suelta/saslprep.py new file mode 100644 index 00000000..fe58d58b --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/saslprep.py @@ -0,0 +1,78 @@ +from __future__ import unicode_literals + +import sys +import stringprep +import unicodedata + + +def saslprep(text, strict=True): + """ + Return a processed version of the given string, using the SASLPrep + profile of stringprep. + + :param text: The string to process, in UTF-8. + :param strict: If ``True``, prevent the use of unassigned code points. + """ + + if sys.version_info < (3, 0): + if type(text) == str: + text = text.decode('us-ascii') + + # Mapping: + # + # - non-ASCII space characters [StringPrep, C.1.2] that can be + # mapped to SPACE (U+0020), and + # + # - the 'commonly mapped to nothing' characters [StringPrep, B.1] + # that can be mapped to nothing. + buffer = '' + for char in text: + if stringprep.in_table_c12(char): + buffer += ' ' + elif not stringprep.in_table_b1(char): + buffer += char + + # Normalization using form KC + text = unicodedata.normalize('NFKC', buffer) + + # Check for bidirectional string + buffer = '' + first_is_randal = False + if text: + first_is_randal = stringprep.in_table_d1(text[0]) + if first_is_randal and not stringprep.in_table_d1(text[-1]): + raise UnicodeError('Section 6.3 [end]') + + # Check for prohibited characters + for x in range(len(text)): + if strict and stringprep.in_table_a1(text[x]): + raise UnicodeError('Unassigned Codepoint') + if stringprep.in_table_c12(text[x]): + raise UnicodeError('In table C.1.2') + if stringprep.in_table_c21(text[x]): + raise UnicodeError('In table C.2.1') + if stringprep.in_table_c22(text[x]): + raise UnicodeError('In table C.2.2') + if stringprep.in_table_c3(text[x]): + raise UnicodeError('In table C.3') + if stringprep.in_table_c4(text[x]): + raise UnicodeError('In table C.4') + if stringprep.in_table_c5(text[x]): + raise UnicodeError('In table C.5') + if stringprep.in_table_c6(text[x]): + raise UnicodeError('In table C.6') + if stringprep.in_table_c7(text[x]): + raise UnicodeError('In table C.7') + if stringprep.in_table_c8(text[x]): + raise UnicodeError('In table C.8') + if stringprep.in_table_c9(text[x]): + raise UnicodeError('In table C.9') + if x: + if first_is_randal and stringprep.in_table_d2(text[x]): + raise UnicodeError('Section 6.2') + if not first_is_randal and \ + x != len(text) - 1 and \ + stringprep.in_table_d1(text[x]): + raise UnicodeError('Section 6.3') + + return text diff --git a/sleekxmpp/thirdparty/suelta/util.py b/sleekxmpp/thirdparty/suelta/util.py new file mode 100644 index 00000000..7d822a81 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/util.py @@ -0,0 +1,118 @@ +""" +""" + +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 |