VirtualMailManager/password.py
author Pascal Volk <neverseen@users.sourceforge.net>
Thu, 29 Apr 2010 03:05:22 +0000
branchv0.6.x
changeset 273 77fc7138ef6a
parent 272 446483386914
child 274 45ec5c3cfef4
permissions -rw-r--r--
VMM/common: added a caching dict for version_hex()/version_str()

# -*- 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
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
cfg_dget = lambda option: None
_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 = [cfg_dget('bin.dovecotpw'), '-s', scheme, '-p',
                get_unicode(password)]
    if cfg_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 cfg_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.
    """
    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 = cfg_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 cfg_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 cfg_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).
    """
    pw_chars = list(PASSWDCHARS)
    shuffle(pw_chars)
    pw_len = cfg_dget('account.password_length')
    if pw_len < 8:
        pw_len = 8
    return ''.join(choice(pw_chars) for x in xrange(pw_len))

del _, cfg_dget