Revision: 8162
          http://svn.sourceforge.net/mailman/?rev=8162&view=rev
Author:   bwarsaw
Date:     2007-03-01 16:07:43 -0800 (Thu, 01 Mar 2007)

Log Message:
-----------
Backport the enum package from the abortive Mailman 3 branch.  This lets us
use much nicer identifiers for constants than plain ints or strings.  New code
using enumerating constants should use subclasses of enum.Enum.

Along those lines, the passwords.py module has been rewritten to use enum
constants instead of strings.  So now e.g. the default password scheme is
Mailman.passwords.Schemes.ssha and there are Scheme.pbkdf2 and Scheme.sha
(etc) schemes as well.

Also, rework the passwords.py implementation to better support unicode
passwords.  This elaborates on Tokio's r8160 by recognizing that the hash
algorithms always operate on byte-strings not on unicodes.  Thus if the secret
or response are unicodes, encode them to byte-strings via utf-8 before hashing
and comparing.

Unit tests added for both enums and passwords.

Modified Paths:
--------------
    trunk/mailman/Mailman/passwords.py

Added Paths:
-----------
    trunk/mailman/Mailman/enum.py
    trunk/mailman/Mailman/testing/test_enum.py
    trunk/mailman/Mailman/testing/test_passwords.py

Added: trunk/mailman/Mailman/enum.py
===================================================================
--- trunk/mailman/Mailman/enum.py                               (rev 0)
+++ trunk/mailman/Mailman/enum.py       2007-03-02 00:07:43 UTC (rev 8162)
@@ -0,0 +1,132 @@
+# Copyright (C) 2004-2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Enumeration meta class.
+
+To define your own enumeration, do something like:
+
+>>> class Colors(Enum):
+...     red = 1
+...     green = 2
+...     blue = 3
+
+Enum subclasses cannot be instantiated, but you can convert them to integers
+and from integers.  Returned enumeration attributes are singletons and can be
+compared by identity only.
+"""
+
+COMMASPACE = ', '
+
+# Based on example by Jeremy Hylton
+# Modified and extended by Barry Warsaw
+
+
+
+class EnumMetaclass(type):
+    def __init__(cls, name, bases, dict):
+        # cls == the class being defined
+        # name == the name of the class
+        # bases == the class's bases
+        # dict == the class attributes
+        super(EnumMetaclass, cls).__init__(name, bases, dict)
+        # Store EnumValues here for easy access.
+        cls._enums = {}
+        # Figure out the set of enum values on the base classes, to ensure
+        # that we don't get any duplicate values (which would screw up
+        # conversion from integer).
+        for basecls in cls.__mro__:
+            if hasattr(basecls, '_enums'):
+                cls._enums.update(basecls._enums)
+        # For each class attribute, create an EnumValue and store that back on
+        # the class instead of the int.  Skip Python reserved names.  Also add
+        # a mapping from the integer to the instance so we can return the same
+        # object on conversion.
+        for attr in dict:
+            if not (attr.startswith('__') and attr.endswith('__')):
+                intval  = dict[attr]
+                enumval = EnumValue(name, intval, attr)
+                if intval in cls._enums:
+                    raise TypeError('Multiple enum values: %s' % enumval)
+                # Store as an attribute on the class, and save the attr name
+                setattr(cls, attr, enumval)
+                cls._enums[intval] = attr
+
+    def __getattr__(cls, name):
+        if name == '__members__':
+            return cls._enums.values()
+        raise AttributeError(name)
+
+    def __repr__(cls):
+        enums = ['%s: %d' % (cls._enums[k], k) for k in sorted(cls._enums)]
+        return '<%s {%s}>' % (cls.__name__, COMMASPACE.join(enums))
+
+    def __iter__(cls):
+        for i in sorted(self._enums):
+            yield self._enums[i]
+
+    def __getitem__(cls, i):
+        # i can be an integer or a string
+        attr = cls._enums.get(i)
+        if attr is None:
+            # It wasn't an integer -- try attribute name
+            try:
+                return getattr(cls, i)
+            except (AttributeError, TypeError):
+                raise ValueError(i)
+        return getattr(cls, attr)
+
+    # Support both MyEnum[i] and MyEnum(i)
+    __call__ = __getitem__
+
+
+
+class EnumValue(object):
+    """Class to represent an enumeration value.
+
+    EnumValue('Color', 'red', 12) prints as 'Color.red' and can be converted
+    to the integer 12.
+    """
+    def __init__(self, classname, value, enumname):
+        self._classname = classname
+        self._value     = value
+        self._enumname  = enumname
+
+    def __repr__(self):
+        return 'EnumValue(%s, %s, %d)' % (
+            self._classname, self._enumname, self._value)
+
+    def __str__(self):
+        return self._enumname
+
+    def __int__(self):
+        return self._value
+
+    # Support only comparison by identity.  Yes, really raise
+    # NotImplementedError instead of returning NotImplemented.
+    def __eq__(self, other):
+        raise NotImplementedError
+
+    __ne__ = __eq__
+    __lt__ = __eq__
+    __gt__ = __eq__
+    __le__ = __eq__
+    __ge__ = __eq__
+
+
+
+class Enum:
+    __metaclass__ = EnumMetaclass

Modified: trunk/mailman/Mailman/passwords.py
===================================================================
--- trunk/mailman/Mailman/passwords.py  2007-03-01 02:49:20 UTC (rev 8161)
+++ trunk/mailman/Mailman/passwords.py  2007-03-02 00:07:43 UTC (rev 8162)
@@ -17,7 +17,7 @@
 
 """Password hashing and verification schemes.
 
-Represents passwords using RFC 2307 syntax.
+Represents passwords using RFC 2307 syntax (as best we can tell).
 """
 
 import os
@@ -29,15 +29,25 @@
 from base64 import urlsafe_b64decode as decode
 from base64 import urlsafe_b64encode as encode
 
+from Mailman.enum import Enum
+
 SALT_LENGTH = 20 # bytes
 ITERATIONS  = 2000
 
+__all__ = [
+    'Schemes',
+    'make_secret',
+    'check_response',
+    ]
 
+
 
 class PasswordScheme(object):
+    TAG = ''
+
     @staticmethod
     def make_secret(password):
-        """Return the hashed password""" 
+        """Return the hashed password"""
         raise NotImplementedError
 
     @staticmethod
@@ -52,9 +62,11 @@
 
 
 class NoPasswordScheme(PasswordScheme):
+    TAG = 'NONE'
+
     @staticmethod
     def make_secret(password):
-        return '{NONE}'
+        return ''
 
     @staticmethod
     def check_response(challenge, response):
@@ -63,9 +75,11 @@
 
 
 class ClearTextPasswordScheme(PasswordScheme):
+    TAG = 'CLEARTEXT'
+
     @staticmethod
     def make_secret(password):
-        return '{CLEARTEXT}' + password
+        return password
 
     @staticmethod
     def check_response(challenge, response):
@@ -74,10 +88,12 @@
 
 
 class SHAPasswordScheme(PasswordScheme):
+    TAG = 'SHA'
+
     @staticmethod
     def make_secret(password):
         h = sha.new(password)
-        return '{SHA}' + encode(h.digest())
+        return encode(h.digest())
 
     @staticmethod
     def check_response(challenge, response):
@@ -87,17 +103,19 @@
 
 
 class SSHAPasswordScheme(PasswordScheme):
+    TAG = 'SSHA'
+
     @staticmethod
     def make_secret(password):
         salt = os.urandom(SALT_LENGTH)
         h = sha.new(password)
         h.update(salt)
-        return '{SSHA}' + encode(h.digest() + salt)
+        return encode(h.digest() + salt)
 
     @staticmethod
     def check_response(challenge, response):
         # Get the salt from the challenge
-        challenge_bytes = decode(str(challenge))
+        challenge_bytes = decode(challenge)
         digest = challenge_bytes[:20]
         salt = challenge_bytes[20:]
         h = sha.new(response)
@@ -106,8 +124,13 @@
 
 
 
-# Given by Bob Fleck
+# Basic algorithm given by Bob Fleck
 class PBKDF2PasswordScheme(PasswordScheme):
+    # This is a bit nasty if we wanted a different prf or iterations.  OTOH,
+    # we really have no clue what the standard LDAP-ish specification for
+    # those options is.
+    TAG = 'PBKDF2 SHA %d' % ITERATIONS
+
     @staticmethod
     def _pbkdf2(password, salt, iterations):
         """From RFC2898 sec. 5.2.  Simplified to handle only 20 byte output
@@ -137,7 +160,7 @@
         salt = os.urandom(SALT_LENGTH)
         digest = PBKDF2PasswordScheme._pbkdf2(password, salt, ITERATIONS)
         derived_key = encode(digest + salt)
-        return '{PBKDF2 SHA %d}' % ITERATIONS + derived_key
+        return derived_key
 
     @staticmethod
     def check_response(challenge, response, prf, iterations):
@@ -157,26 +180,60 @@
 
 
 
-SCHEMES = {
-    'none'      : NoPasswordScheme,
-    'cleartext' : ClearTextPasswordScheme,
-    'sha'       : SHAPasswordScheme,
-    'ssha'      : SSHAPasswordScheme,
-    'pbkdf2'    : PBKDF2PasswordScheme,
+class Schemes(Enum):
+    # no_scheme is deliberately ugly because no one should be using it.  Yes,
+    # this makes cleartext inconsistent, but that's a common enough
+    # terminology to justify the missing underscore.
+    no_scheme   = 1
+    cleartext   = 2
+    sha         = 3
+    ssha        = 4
+    pbkdf2      = 5
+
+
+_SCHEMES_BY_ENUM = {
+    Schemes.no_scheme   : NoPasswordScheme,
+    Schemes.cleartext   : ClearTextPasswordScheme,
+    Schemes.sha         : SHAPasswordScheme,
+    Schemes.ssha        : SSHAPasswordScheme,
+    Schemes.pbkdf2      : PBKDF2PasswordScheme,
     }
 
 
-def make_secret(password, scheme):
-    scheme_class = SCHEMES.get(scheme.lower(), NoPasswordScheme)
-    return scheme_class.make_secret(password)
+# Some scheme tags have arguments, but the key for this dictionary should just
+# be the lowercased scheme name.
+_SCHEMES_BY_TAG = dict((c.TAG.split(' ')[0].lower(), c)
+                       for c in _SCHEMES_BY_ENUM.values())
 
+_DEFAULT_SCHEME = NoPasswordScheme
 
+
+
+def make_secret(password, scheme=None):
+    # The hash algorithms operate on bytes not strings.  The password argument
+    # as provided here by the client will be a string (in Python 2 either
+    # unicode or 8-bit, in Python 3 always unicode).  We need to encode this
+    # string into a byte array, and the way to spell that in Python 2 is to
+    # encode the string to utf-8.  The returned secret is a string, so it must
+    # be a unicode.
+    if isinstance(password, unicode):
+        password = password.encode('utf-8')
+    scheme_class = _SCHEMES_BY_ENUM.get(scheme, _DEFAULT_SCHEME)
+    secret = scheme_class.make_secret(password)
+    return '{%s}%s' % (scheme_class.TAG, secret)
+
+
 def check_response(challenge, response):
     mo = re.match(r'{(?P<scheme>[^}]+?)}(?P<rest>.*)',
                   challenge, re.IGNORECASE)
     if not mo:
         return False
-    scheme, rest = mo.group('scheme', 'rest')
-    scheme_parts = scheme.split()
-    scheme_class = SCHEMES.get(scheme_parts[0].lower(), NoPasswordScheme)
-    return scheme_class.check_response(rest, response, *scheme_parts[1:])
+    # See above for why we convert here.  However because we should have
+    # generated the challenge, we assume that it is already a byte string.
+    if isinstance(response, unicode):
+        response = response.encode('utf-8')
+    scheme_group, rest_group = mo.group('scheme', 'rest')
+    scheme_parts = scheme_group.split()
+    scheme       = scheme_parts[0].lower()
+    scheme_class = _SCHEMES_BY_TAG.get(scheme, _DEFAULT_SCHEME)
+    return scheme_class.check_response(rest_group, response, *scheme_parts[1:])

Added: trunk/mailman/Mailman/testing/test_enum.py
===================================================================
--- trunk/mailman/Mailman/testing/test_enum.py                          (rev 0)
+++ trunk/mailman/Mailman/testing/test_enum.py  2007-03-02 00:07:43 UTC (rev 
8162)
@@ -0,0 +1,117 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Unit tests for Enums."""
+
+import operator
+import unittest
+
+from Mailman.enum import Enum
+
+
+
+class Colors(Enum):
+    red     = 1
+    green   = 2
+    blue    = 3
+
+
+class MoreColors(Colors):
+    pink    = 4
+    cyan    = 5
+
+
+class OtherColors(Enum):
+    red     = 1
+    blue    = 2
+    yellow  = 3
+
+
+
+class TestEnum(unittest.TestCase):
+    def test_enum_basics(self):
+        unless = self.failUnless
+        raises = self.assertRaises
+        # Cannot compare by equality
+        raises(NotImplementedError, operator.eq, Colors.red, Colors.red)
+        raises(NotImplementedError, operator.ne, Colors.red, Colors.red)
+        raises(NotImplementedError, operator.lt, Colors.red, Colors.red)
+        raises(NotImplementedError, operator.gt, Colors.red, Colors.red)
+        raises(NotImplementedError, operator.le, Colors.red, Colors.red)
+        raises(NotImplementedError, operator.ge, Colors.red, Colors.red)
+        raises(NotImplementedError, operator.eq, Colors.red, 1)
+        raises(NotImplementedError, operator.ne, Colors.red, 1)
+        raises(NotImplementedError, operator.lt, Colors.red, 1)
+        raises(NotImplementedError, operator.gt, Colors.red, 1)
+        raises(NotImplementedError, operator.le, Colors.red, 1)
+        raises(NotImplementedError, operator.ge, Colors.red, 1)
+        # Comparison by identity
+        unless(Colors.red is Colors.red)
+        unless(Colors.red is MoreColors.red)
+        unless(Colors.red is not OtherColors.red)
+        unless(Colors.red is not Colors.blue)
+
+    def test_enum_conversions(self):
+        eq = self.assertEqual
+        unless = self.failUnless
+        raises = self.assertRaises
+        unless(Colors.red is Colors['red'])
+        unless(Colors.red is Colors[1])
+        unless(Colors.red is Colors('red'))
+        unless(Colors.red is Colors(1))
+        unless(Colors.red is not Colors['blue'])
+        unless(Colors.red is not Colors[2])
+        unless(Colors.red is not Colors('blue'))
+        unless(Colors.red is not Colors(2))
+        unless(Colors.red is MoreColors['red'])
+        unless(Colors.red is MoreColors[1])
+        unless(Colors.red is MoreColors('red'))
+        unless(Colors.red is MoreColors(1))
+        unless(Colors.red is not OtherColors['red'])
+        unless(Colors.red is not OtherColors[1])
+        unless(Colors.red is not OtherColors('red'))
+        unless(Colors.red is not OtherColors(1))
+        raises(ValueError, Colors.__getitem__, 'magenta')
+        raises(ValueError, Colors.__getitem__, 99)
+        raises(ValueError, Colors.__call__, 'magenta')
+        raises(ValueError, Colors.__call__, 99)
+        eq(int(Colors.red), 1)
+        eq(int(Colors.blue), 3)
+        eq(int(MoreColors.red), 1)
+        eq(int(OtherColors.blue), 2)
+        
+
+    def test_enum_duplicates(self):
+        try:
+            class Bad(Enum):
+                cartman = 1
+                stan    = 2
+                kyle    = 3
+                kenny   = 3
+                butters = 4
+        except TypeError:
+            got_error = True
+        else:
+            got_error = False
+        self.failUnless(got_error)
+
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(TestEnum))
+    return suite

Added: trunk/mailman/Mailman/testing/test_passwords.py
===================================================================
--- trunk/mailman/Mailman/testing/test_passwords.py                             
(rev 0)
+++ trunk/mailman/Mailman/testing/test_passwords.py     2007-03-02 00:07:43 UTC 
(rev 8162)
@@ -0,0 +1,129 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Unit tests for the passwords module."""
+
+import unittest
+
+from Mailman import passwords
+
+
+
+class TestPasswordsBase(unittest.TestCase):
+    scheme = None
+
+    def setUp(self):
+        # passwords; 8-bit or unicode strings; ascii or binary
+        self.pw8a       = 'abc'
+        self.pwua       = u'abc'
+        self.pw8b       = 'abc\xc3\xbf'     # 'abc\xff'
+        self.pwub       = u'abc\xff'
+        # bad password; 8-bit or unicode; ascii or binary
+        self.bad8a      = 'xyz'
+        self.badua      = u'xyz'
+        self.bad8b      = 'xyz\xc3\xbf'     # 'xyz\xff'
+        self.badub      = u'xyz\xff'
+
+    def test_passwords(self):
+        unless = self.failUnless
+        failif = self.failIf
+        secret = passwords.make_secret(self.pw8a, self.scheme)
+        unless(passwords.check_response(secret, self.pw8a))
+        failif(passwords.check_response(secret, self.bad8a))
+
+    def test_unicode_passwords(self):
+        unless = self.failUnless
+        failif = self.failIf
+        secret = passwords.make_secret(self.pwua, self.scheme)
+        unless(passwords.check_response(secret, self.pwua))
+        failif(passwords.check_response(secret, self.badua))
+
+    def test_passwords_with_funky_chars(self):
+        unless = self.failUnless
+        failif = self.failIf
+        secret = passwords.make_secret(self.pw8b, self.scheme)
+        unless(passwords.check_response(secret, self.pw8b))
+        failif(passwords.check_response(secret, self.bad8b))
+
+    def test_unicode_passwords_with_funky_chars(self):
+        unless = self.failUnless
+        failif = self.failIf
+        secret = passwords.make_secret(self.pwub, self.scheme)
+        unless(passwords.check_response(secret, self.pwub))
+        failif(passwords.check_response(secret, self.badub))
+
+
+
+class TestBogusPasswords(TestPasswordsBase):
+    scheme = -1
+
+    def test_passwords(self):
+        failif = self.failIf
+        secret = passwords.make_secret(self.pw8a, self.scheme)
+        failif(passwords.check_response(secret, self.pw8a))
+        failif(passwords.check_response(secret, self.bad8a))
+
+    def test_unicode_passwords(self):
+        failif = self.failIf
+        secret = passwords.make_secret(self.pwua, self.scheme)
+        failif(passwords.check_response(secret, self.pwua))
+        failif(passwords.check_response(secret, self.badua))
+
+    def test_passwords_with_funky_chars(self):
+        failif = self.failIf
+        secret = passwords.make_secret(self.pw8b, self.scheme)
+        failif(passwords.check_response(secret, self.pw8b))
+        failif(passwords.check_response(secret, self.bad8b))
+
+    def test_unicode_passwords_with_funky_chars(self):
+        failif = self.failIf
+        secret = passwords.make_secret(self.pwub, self.scheme)
+        failif(passwords.check_response(secret, self.pwub))
+        failif(passwords.check_response(secret, self.badub))
+
+
+
+class TestNonePasswords(TestBogusPasswords):
+    scheme = passwords.Schemes.no_scheme
+
+
+class TestCleartextPasswords(TestPasswordsBase):
+    scheme = passwords.Schemes.cleartext
+
+
+class TestSHAPasswords(TestPasswordsBase):
+    scheme = passwords.Schemes.sha
+
+
+class TestSSHAPasswords(TestPasswordsBase):
+    scheme = passwords.Schemes.ssha
+
+
+class TestPBKDF2Passwords(TestPasswordsBase):
+    scheme = passwords.Schemes.pbkdf2
+
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(TestBogusPasswords))
+    suite.addTest(unittest.makeSuite(TestNonePasswords))
+    suite.addTest(unittest.makeSuite(TestCleartextPasswords))
+    suite.addTest(unittest.makeSuite(TestSHAPasswords))
+    suite.addTest(unittest.makeSuite(TestSSHAPasswords))
+    suite.addTest(unittest.makeSuite(TestPBKDF2Passwords))
+    return suite


This was sent by the SourceForge.net collaborative development platform, the 
world's largest Open Source development site.
_______________________________________________
Mailman-checkins mailing list
[email protected]
Unsubscribe: 
http://mail.python.org/mailman/options/mailman-checkins/archive%40jab.org

Reply via email to