diff options
Diffstat (limited to 'src/mailman/passwords.py')
| -rw-r--r-- | src/mailman/passwords.py | 253 |
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()) |
