VMM: added new modules password and pycompat.hashlib. v0.6.x
authorPascal Volk <neverseen@users.sourceforge.net>
Wed, 28 Apr 2010 03:34:57 +0000
branchv0.6.x
changeset 268 beb8f4421f92
parent 267 084300a00ee1
child 269 188ea8d6072f
VMM: added new modules password and pycompat.hashlib. INSTALL: updated
INSTALL
VirtualMailManager/password.py
VirtualMailManager/pycompat/hashlib.py
--- a/INSTALL	Wed Apr 28 02:24:23 2010 +0000
+++ b/INSTALL	Wed Apr 28 03:34:57 2010 +0000
@@ -1,11 +1,25 @@
 Installation Prerequisites
 You should already have installed and configured Postfix, Dovecot and
 PostgreSQL.
-You have to install Python and pyPgSQL* to use the Virtual Mail Manager.
-If you want to store the passwords as PLAIN-MD4 digest you have also to install
-python-crypto <http://www.amk.ca/python/code/crypto.html>.
+
+The Virtual Mail Manager depends on:
+    - Python (>= 2.4.0)
+    - pyPgSQL¹
 
-* = http://pypgsql.sourceforge.net/ (Debian: python-pgsql)
+If you are using Python <= 2.5.0:
+    - if you want to store your users' passwords as PLAIN-MD4 digest in
+      the database, vmm will try to use Crypto.Hash.MD4 from PyCrypto².
+    - if you are using Dovecot >= v1.1.0 and you want to store your users'
+      passwords as SHA256 or SSHA256 hashes, vmm will try to use
+      Crypto.Hash.SHA256 from PyCrypto². For SHA256/SSHA256 you should have
+      at least use PyCrypto in version 2.1.0alpha1.
+
+    When the Crypto.Hash module couldn't be imported, vmm will use
+    dovecotpw/doveadm, if the misc.password_scheme setting in the vmm.cfg
+    is set to PLAIN-MD4, SHA256 or SSHA256
+
+[1] pyPgSQL: <http://pypgsql.sourceforge.net/> (Debian: python-pgsql)
+[2] PyCrypto: <http://www.pycrypto.org/> (Debian: python-crypto)
 
 
 Configuring PostgreSQL
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/VirtualMailManager/password.py	Wed Apr 28 03:34:57 2010 +0000
@@ -0,0 +1,342 @@
+# -*- coding: UTF-8 -*-
+# Copyright (c) 2010, Pascal Volk
+# See COPYING for distribution information.
+
+"""
+    VirtualMailManager.password
+
+    VirtualMailManager's password module to generate password hashes from
+    passwords or random passwords. There are two functions:
+
+        hashed_password = pwhash(password[, scheme][, user])
+        random_password = randompw()
+"""
+
+from crypt import crypt
+from random import choice, shuffle
+from subprocess import Popen, PIPE
+
+try:
+    import hashlib
+except ImportError:
+    from VirtualMailManager.pycompat import hashlib
+
+from VirtualMailManager import ENCODING, Configuration
+from VirtualMailManager.EmailAddress import EmailAddress
+from VirtualMailManager.common import get_unicode, version_str
+from VirtualMailManager.constants.ERROR import VMM_ERROR
+from VirtualMailManager.errors import VMMError
+
+COMPAT = hasattr(hashlib, 'compat')
+SALTCHARS = './0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+PASSWDCHARS = '._-+#*23456789abcdefghikmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
+DEFAULT_B64 = (None, 'B64', 'BASE64')
+DEFAULT_HEX = (None, 'HEX')
+
+_ = lambda msg: msg
+_get_salt = lambda s_len: ''.join(choice(SALTCHARS) for x in xrange(s_len))
+
+
+def _dovecotpw(password, scheme, encoding):
+    """Communicates with dovecotpw (Dovecot 2.0: `doveadm pw`) and returns
+    the hashed password: {scheme[.encoding]}hash
+    """
+    if encoding:
+        scheme = '.'.join((scheme, encoding))
+    cmd_args = [Configuration.dget('bin.dovecotpw'), '-s', scheme, '-p',
+                get_unicode(password)]
+    if Configuration.dget('misc.dovecot_version') >= 0x20000a01:
+        cmd_args.insert(1, 'pw')
+    process = Popen(cmd_args, stdout=PIPE, stderr=PIPE)
+    stdout, stderr = process.communicate()
+    if process.returncode:
+        raise VMMError(stderr.strip(), VMM_ERROR)
+    return stdout.strip()
+
+
+def _md4_new():
+    """Returns an new MD4-hash object if supported by the hashlib or
+    provided by PyCrypto - other `None`.
+    """
+    try:
+        return hashlib.new('md4')
+    except ValueError, err:
+        if str(err) == 'unsupported hash type':
+            if not COMPAT:
+                try:
+                    from Crypto.Hash import MD4
+                    return MD4.new()
+                except ImportError:
+                    return None
+        else:
+            raise
+
+
+def _sha256_new(data=''):
+    """Returns a new sha256 object from the hashlib.
+
+    Returns `None` if the PyCrypto in pycompat.hashlib is too old."""
+    if not COMPAT:
+        return hashlib.sha256(data)
+    try:
+        return hashlib.new('sha256', data)
+    except ValueError, err:
+        if str(err) == 'unsupported hash type':
+            return None
+        else:
+            raise
+
+
+def _format_digest(digest, scheme, encoding):
+    """Formats the arguments to a string: {scheme[.encoding]}digest."""
+    if not encoding:
+        return '{%s}%s' % (scheme, digest)
+    return '{%s.%s}%s' % (scheme, encoding, digest)
+
+
+def _clear_hash(password, scheme, encoding):
+    """Generates a (encoded) CLEARTEXT/PLAIN 'hash'."""
+    if encoding:
+        if encoding == 'HEX':
+            password = password.encode('hex')
+        else:
+            password = password.encode('base64').replace('\n', '')
+        return _format_digest(password, scheme, encoding)
+    return get_unicode('{%s}%s' % (scheme, password))
+
+
+def _crypt_hash(password, scheme, encoding):
+    """Generates (encoded) CRYPT/MD5/MD5-CRYPT hashes."""
+    if scheme == 'CRYPT':
+        salt = _get_salt(2)
+    else:
+        salt = '$1$%s$' % _get_salt(8)
+    encrypted = crypt(password, salt)
+    if encoding:
+        if encoding == 'HEX':
+            encrypted = encrypted.encode('hex')
+        else:
+            encrypted = encrypted.encode('base64').rstrip()
+    return _format_digest(encrypted, scheme, encoding)
+
+
+def _md4_hash(password, scheme, encoding):
+    """Generates encoded PLAIN-MD4 hashes."""
+    md4 = _md4_new()
+    if md4:
+        md4.update(password)
+        if encoding in DEFAULT_HEX:
+            digest = md4.hexdigest()
+        else:
+            digest = md4.digest().encode('base64').rstrip()
+        return _format_digest(digest, scheme, encoding)
+    return _dovecotpw(password, scheme, encoding)
+
+
+def _md5_hash(password, scheme, encoding, user=None):
+    """Generates DIGEST-MD5 aka PLAIN-MD5 and LDAP-MD5 hashes."""
+    md5 = hashlib.md5()
+    if scheme == 'DIGEST-MD5':
+        #  Prior to Dovecot v1.1.12/v1.2.beta2 there was a problem with a
+        #  empty auth_realms setting in dovecot.conf and user@domain.tld
+        #  usernames. So we have to generate different hashes for different
+        #  versions. See also:
+        #       http://dovecot.org/list/dovecot-news/2009-March/000103.html
+        #       http://hg.dovecot.org/dovecot-1.1/rev/2b0043ba89ae
+        if Configuration.dget('misc.dovecot_version') >= 0x1010cf00:
+            md5.update('%s:%s:' % (user.localpart, user.domainname))
+        else:
+            md5.update('%s::' % user)
+    md5.update(password)
+    if (scheme in ('PLAIN-MD5', 'DIGEST-MD5') and encoding in DEFAULT_HEX) \
+      or (scheme == 'LDAP-MD5' and encoding == 'HEX'):
+        digest = md5.hexdigest()
+    else:
+        digest = md5.digest().encode('base64').rstrip()
+    return _format_digest(digest, scheme, encoding)
+
+
+def _ntlm_hash(password, scheme, encoding):
+    """Generates NTLM hashes."""
+    md4 = _md4_new()
+    if md4:
+        password = ''.join('%s\x00' % c for c in password)
+        md4.update(password)
+        if encoding in DEFAULT_HEX:
+            digest = md4.hexdigest()
+        else:
+            digest = md4.digest().encode('base64').rstrip()
+        return _format_digest(digest, scheme, encoding)
+    return _dovecotpw(password, scheme, encoding)
+
+
+def _sha1_hash(password, scheme, encoding):
+    """Generates SHA1 aka SHA hashes."""
+    sha1 = hashlib.sha1(password)
+    if encoding in DEFAULT_B64:
+        digest = sha1.digest().encode('base64').rstrip()
+    else:
+        digest = sha1.hexdigest()
+    return _format_digest(digest, scheme, encoding)
+
+
+def _sha256_hash(password, scheme, encoding):
+    """Generates SHA256 hashes."""
+    sha256 = _sha256_new(password)
+    if sha256:
+        if encoding in DEFAULT_B64:
+            digest = sha256.digest().encode('base64').rstrip()
+        else:
+            digest = sha256.hexdigest()
+        return _format_digest(digest, scheme, encoding)
+    return _dovecotpw(password, scheme, encoding)
+
+
+def _sha512_hash(password, scheme, encoding):
+    """Generates SHA512 hashes."""
+    if not COMPAT:
+        sha512 = hashlib.sha512(password)
+        if encoding in DEFAULT_B64:
+            digest = sha512.digest().encode('base64').replace('\n', '')
+        else:
+            digest = sha512.hexdigest()
+        return _format_digest(digest, scheme, encoding)
+    return _dovecotpw(password, scheme, encoding)
+
+
+def _smd5_hash(password, scheme, encoding):
+    """Generates SMD5 (salted PLAIN-MD5) hashes."""
+    md5 = hashlib.md5(password)
+    salt = _get_salt(4)
+    md5.update(salt)
+    if encoding in DEFAULT_B64:
+        digest = (md5.digest() + salt).encode('base64').rstrip()
+    else:
+        digest = md5.hexdigest() + salt.encode('hex')
+    return _format_digest(digest, scheme, encoding)
+
+
+def _ssha1_hash(password, scheme, encoding):
+    """Generates SSHA (salted SHA/SHA1) hashes."""
+    sha1 = hashlib.sha1(password)
+    salt = _get_salt(4)
+    sha1.update(salt)
+    if encoding in DEFAULT_B64:
+        digest = (sha1.digest() + salt).encode('base64').rstrip()
+    else:
+        digest = sha1.hexdigest() + salt.encode('hex')
+    return _format_digest(digest, scheme, encoding)
+
+
+def _ssha256_hash(password, scheme, encoding):
+    """Generates SSHA256 (salted SHA256) hashes."""
+    sha256 = _sha256_new(password)
+    if sha256:
+        salt = _get_salt(4)
+        sha256.update(salt)
+        if encoding in DEFAULT_B64:
+            digest = (sha256.digest() + salt).encode('base64').rstrip()
+        else:
+            digest = sha256.hexdigest() + salt.encode('hex')
+        return _format_digest(digest, scheme, encoding)
+    return _dovecotpw(password, scheme, encoding)
+
+
+def _ssha512_hash(password, scheme, encoding):
+    """Generates SSHA512 (salted SHA512) hashes."""
+    if not COMPAT:
+        salt = _get_salt(4)
+        sha512 = hashlib.sha512(password + salt)
+        if encoding in DEFAULT_B64:
+            digest = (sha512.digest() + salt).encode('base64').replace('\n',
+                                                                       '')
+        else:
+            digest = sha512.hexdigest() + salt.encode('hex')
+        return _format_digest(digest, scheme, encoding)
+    return _dovecotpw(password, scheme, encoding)
+
+_scheme_info = {
+    'CLEARTEXT': (_clear_hash, 0x10000f00),
+    'CRAM-MD5': (_dovecotpw, 0x10000f00),
+    'CRYPT': (_crypt_hash, 0x10000f00),
+    'DIGEST-MD5': (_md5_hash, 0x10000f00),
+    'HMAC-MD5': (_dovecotpw, 0x10000f00),
+    'LANMAN': (_dovecotpw, 0x10000f00),
+    'LDAP-MD5': (_md5_hash, 0x10000f00),
+    'MD5': (_crypt_hash, 0x10000f00),
+    'MD5-CRYPT': (_crypt_hash, 0x10000f00),
+    'NTLM': (_ntlm_hash, 0x10000f00),
+    'OTP': (_dovecotpw, 0x10100a01),
+    'PLAIN': (_clear_hash, 0x10000f00),
+    'PLAIN-MD4': (_md4_hash, 0x10000f00),
+    'PLAIN-MD5': (_md5_hash, 0x10000f00),
+    'RPA': (_dovecotpw, 0x10000f00),
+    'SHA': (_sha1_hash, 0x10000f00),
+    'SHA1': (_sha1_hash, 0x10000f00),
+    'SHA256': (_sha256_hash, 0x10100a01),
+    'SHA512': (_sha512_hash, 0x20000b03),
+    'SKEY': (_dovecotpw, 0x10100a01),
+    'SMD5': (_smd5_hash, 0x10000f00),
+    'SSHA': (_ssha1_hash, 0x10000f00),
+    'SSHA256': (_ssha256_hash, 0x10200a04),
+    'SSHA512': (_ssha512_hash, 0x20000b03),
+}
+
+
+def pwhash(password, scheme=None, user=None):
+    """Generates a password hash from the plain text *password* string.
+
+    If no *scheme* is given the password scheme from the configuration will
+    be used for the hash generation.  When 'DIGEST-MD5' is used as scheme,
+    also an EmailAddress instance must be given as *user* argument.
+    """
+    assert Configuration is not None
+    if not isinstance(password, basestring):
+        raise TypeError('Password is not a string: %r' % password)
+    if isinstance(password, unicode):
+        password = password.encode(ENCODING)
+    password = password.strip()
+    if not password:
+        raise ValueError("Couldn't accept empty password.")
+    if scheme is None:
+        scheme = Configuration.dget('misc.password_scheme')
+    scheme_encoding = scheme.split('.')
+    scheme = scheme_encoding[0].upper()
+    if not scheme in _scheme_info:
+        raise VMMError(_(u"Unsupported password scheme: '%s'") % scheme,
+                       VMM_ERROR)
+    if Configuration.dget('misc.dovecot_version') < _scheme_info[scheme][1]:
+        raise VMMError(_(u"The scheme '%s' requires Dovecot >= v%s") %
+                       (scheme, version_str(_scheme_info[scheme][1])),
+                       VMM_ERROR)
+    if len(scheme_encoding) > 1:
+        if Configuration.dget('misc.dovecot_version') < 0x10100a01:
+            raise VMMError(_(u'Encoding suffixes for password schemes require \
+Dovecot >= v1.1.alpha1'),
+                           VMM_ERROR)
+        if scheme_encoding[1].upper() not in ('B64', 'BASE64', 'HEX'):
+            raise ValueError('Unsupported encoding: %r' % scheme_encoding[1])
+        encoding = scheme_encoding[1].upper()
+    else:
+        encoding = None
+    if scheme == 'DIGEST-MD5':
+        assert isinstance(user, EmailAddress)
+        return _md5_hash(password, scheme, encoding, user)
+    return _scheme_info[scheme][0](password, scheme, encoding)
+
+
+def randompw():
+    """Generates a plain text random password.
+
+    The length of the password can be configured in the ``vmm.cfg``
+    (account.password_length).
+    """
+    assert Configuration is not None
+    pw_chars = list(PASSWDCHARS)
+    shuffle(pw_chars)
+    pw_len = Configuration.dget('account.password_length')
+    if pw_len < 8:
+        pw_len = 8
+    return ''.join(choice(pw_chars) for x in xrange(pw_len))
+
+del _
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/VirtualMailManager/pycompat/hashlib.py	Wed Apr 28 03:34:57 2010 +0000
@@ -0,0 +1,58 @@
+# -*- coding: UTF-8 -*-
+# Copyright (c) 2010, Pascal Volk
+# See COPYING for distribution information.
+
+"""
+    VirtualMailManager.pycompat.hashlib
+
+    VirtualMailManager's minimal hashlib emulation for Python 2.4
+
+    hashlib.md5(...), hashlib.sha1(...), hashlib.new('md5', ...) and
+    hashlib.new('sha1', ...) will work always.
+
+    When the PyCrypto module <http://www.pycrypto.org/> could be found in
+    sys.path hashlib.new('md4', ...) will also work.
+
+    With PyCrypto >= 2.1.0alpha1 hashlib.new('sha256', ...) and
+    hashlib.sha256(...) becomes functional.
+"""
+
+
+import md5 as _md5
+import sha as _sha1
+
+try:
+    import Crypto
+except ImportError:
+    _md4 = None
+    SHA256 = None
+else:
+    from Crypto.Hash import MD4 as _md4
+    if hasattr(Crypto, 'version_info'):  # <- Available since v2.1.0alpha1
+        from Crypto.Hash import SHA256   # SHA256 works since v2.1.0alpha1
+        sha256 = SHA256.new
+    else:
+        SHA256 = None
+    del Crypto
+
+
+compat = 0x01
+md5 = _md5.new
+sha1 = _sha1.new
+
+
+def new(name, string=''):
+    """Return a new hashing object using the named algorithm, optionally
+    initialized with the provided string.
+    """
+    if name in ('md5', 'MD5'):
+        return _md5.new(string)
+    if name in ('sha1', 'SHA1'):
+        return _sha1.new(string)
+    if not _md4:
+        raise ValueError('unsupported hash type')
+    if name in ('md4', 'MD4'):
+        return _md4.new(string)
+    if name in ('sha256', 'SHA256') and SHA256:
+        return SHA256.new(string)
+    raise ValueError('unsupported hash type')