summaryrefslogtreecommitdiff
path: root/src/mailman/passwords.py
diff options
context:
space:
mode:
authorBarry Warsaw2009-01-25 13:01:41 -0500
committerBarry Warsaw2009-01-25 13:01:41 -0500
commiteefd06f1b88b8ecbb23a9013cd223b72ca85c20d (patch)
tree72c947fe16fce0e07e996ee74020b26585d7e846 /src/mailman/passwords.py
parent07871212f74498abd56bef3919bf3e029eb8b930 (diff)
downloadmailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.gz
mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.zst
mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.zip
Diffstat (limited to 'src/mailman/passwords.py')
-rw-r--r--src/mailman/passwords.py253
1 files changed, 253 insertions, 0 deletions
diff --git a/src/mailman/passwords.py b/src/mailman/passwords.py
new file mode 100644
index 000000000..0c8284c1c
--- /dev/null
+++ b/src/mailman/passwords.py
@@ -0,0 +1,253 @@
+# Copyright (C) 2007-2009 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman 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 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman 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
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Password hashing and verification schemes.
+
+Represents passwords using RFC 2307 syntax (as best we can tell).
+"""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Schemes',
+ 'make_secret',
+ 'check_response',
+ ]
+
+
+import os
+import re
+import hmac
+import hashlib
+
+from array import array
+from base64 import urlsafe_b64decode as decode
+from base64 import urlsafe_b64encode as encode
+from munepy import Enum
+
+from mailman.core import errors
+
+SALT_LENGTH = 20 # bytes
+ITERATIONS = 2000
+
+
+
+class PasswordScheme:
+ TAG = b''
+
+ @staticmethod
+ def make_secret(password):
+ """Return the hashed password"""
+ raise NotImplementedError
+
+ @staticmethod
+ def check_response(challenge, response):
+ """Return True if response matches challenge.
+
+ It is expected that the scheme specifier prefix is already stripped
+ from the response string.
+ """
+ raise NotImplementedError
+
+
+
+class NoPasswordScheme(PasswordScheme):
+ TAG = b'NONE'
+
+ @staticmethod
+ def make_secret(password):
+ return b''
+
+ @staticmethod
+ def check_response(challenge, response):
+ return False
+
+
+
+class ClearTextPasswordScheme(PasswordScheme):
+ TAG = b'CLEARTEXT'
+
+ @staticmethod
+ def make_secret(password):
+ return password
+
+ @staticmethod
+ def check_response(challenge, response):
+ return challenge == response
+
+
+
+class SHAPasswordScheme(PasswordScheme):
+ TAG = b'SHA'
+
+ @staticmethod
+ def make_secret(password):
+ h = hashlib.sha1(password)
+ return encode(h.digest())
+
+ @staticmethod
+ def check_response(challenge, response):
+ h = hashlib.sha1(response)
+ return challenge == encode(h.digest())
+
+
+
+class SSHAPasswordScheme(PasswordScheme):
+ TAG = b'SSHA'
+
+ @staticmethod
+ def make_secret(password):
+ salt = os.urandom(SALT_LENGTH)
+ h = hashlib.sha1(password)
+ h.update(salt)
+ return encode(h.digest() + salt)
+
+ @staticmethod
+ def check_response(challenge, response):
+ # Get the salt from the challenge
+ challenge_bytes = decode(challenge)
+ digest = challenge_bytes[:20]
+ salt = challenge_bytes[20:]
+ h = hashlib.sha1(response)
+ h.update(salt)
+ return digest == h.digest()
+
+
+
+# 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 = b'PBKDF2 SHA {0}'.format(ITERATIONS)
+
+ @staticmethod
+ def _pbkdf2(password, salt, iterations):
+ """From RFC2898 sec. 5.2. Simplified to handle only 20 byte output
+ case. Output of 20 bytes means always exactly one block to handle,
+ and a constant block counter appended to the salt in the initial hmac
+ update.
+ """
+ h = hmac.new(password, None, hashlib.sha1)
+ prf = h.copy()
+ prf.update(salt + b'\x00\x00\x00\x01')
+ T = U = array(b'l', prf.digest())
+ while iterations:
+ prf = h.copy()
+ prf.update(U.tostring())
+ U = array(b'l', prf.digest())
+ T = array(b'l', (t ^ u for t, u in zip(T, U)))
+ iterations -= 1
+ return T.tostring()
+
+ @staticmethod
+ def make_secret(password):
+ """From RFC2898 sec. 5.2. Simplified to handle only 20 byte output
+ case. Output of 20 bytes means always exactly one block to handle,
+ and a constant block counter appended to the salt in the initial hmac
+ update.
+ """
+ salt = os.urandom(SALT_LENGTH)
+ digest = PBKDF2PasswordScheme._pbkdf2(password, salt, ITERATIONS)
+ derived_key = encode(digest + salt)
+ return derived_key
+
+ @staticmethod
+ def check_response(challenge, response, prf, iterations):
+ # Decode the challenge to get the number of iterations and salt
+ # XXX we don't support anything but sha prf
+ if prf.lower() <> b'sha':
+ return False
+ try:
+ iterations = int(iterations)
+ except (ValueError, TypeError):
+ return False
+ challenge_bytes = decode(challenge)
+ digest = challenge_bytes[:20]
+ salt = challenge_bytes[20:]
+ key = PBKDF2PasswordScheme._pbkdf2(response, salt, iterations)
+ return digest == key
+
+
+
+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,
+ }
+
+
+# Some scheme tags have arguments, but the key for this dictionary should just
+# be the lowercased scheme name.
+_SCHEMES_BY_TAG = dict((_SCHEMES_BY_ENUM[e].TAG.split(' ')[0].lower(), e)
+ for e in _SCHEMES_BY_ENUM)
+
+_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)
+ if not scheme_class:
+ raise errors.BadPasswordSchemeError(scheme)
+ secret = scheme_class.make_secret(password)
+ return b'{{{0}}}{1}'.format(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
+ # 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_enum = _SCHEMES_BY_TAG.get(scheme, _DEFAULT_SCHEME)
+ scheme_class = _SCHEMES_BY_ENUM[scheme_enum]
+ if isinstance(rest_group, unicode):
+ rest_group = rest_group.encode('utf-8')
+ return scheme_class.check_response(rest_group, response, *scheme_parts[1:])
+
+
+def lookup_scheme(scheme_name):
+ return _SCHEMES_BY_TAG.get(scheme_name.lower())