VirtualMailManager/password.py
changeset 571 a4aead244f75
parent 568 14abdd04ddf5
child 633 8ea32fbfc9df
equal deleted inserted replaced
465:c0e1fb1b0145 571:a4aead244f75
       
     1 # -*- coding: UTF-8 -*-
       
     2 # Copyright (c) 2010 - 2012, Pascal Volk
       
     3 # See COPYING for distribution information.
       
     4 """
       
     5     VirtualMailManager.password
       
     6     ~~~~~~~~~~~~~~~~~~~~~~~~~~~
       
     7 
       
     8     VirtualMailManager's password module to generate password hashes from
       
     9     passwords or random passwords. This module provides following
       
    10     functions:
       
    11 
       
    12         hashed_password = pwhash(password[, scheme][, user])
       
    13         random_password = randompw()
       
    14         scheme, encoding = verify_scheme(scheme)
       
    15         schemes, encodings = list_schemes()
       
    16 """
       
    17 
       
    18 from crypt import crypt
       
    19 from random import SystemRandom
       
    20 from subprocess import Popen, PIPE
       
    21 
       
    22 try:
       
    23     import hashlib
       
    24 except ImportError:
       
    25     from VirtualMailManager.pycompat import hashlib
       
    26 
       
    27 from VirtualMailManager import ENCODING
       
    28 from VirtualMailManager.emailaddress import EmailAddress
       
    29 from VirtualMailManager.common import get_unicode, version_str
       
    30 from VirtualMailManager.constants import VMM_ERROR
       
    31 from VirtualMailManager.errors import VMMError
       
    32 
       
    33 COMPAT = hasattr(hashlib, 'compat')
       
    34 SALTCHARS = './0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
       
    35 PASSWDCHARS = '._-+#*23456789abcdefghikmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
       
    36 DEFAULT_B64 = (None, 'B64', 'BASE64')
       
    37 DEFAULT_HEX = (None, 'HEX')
       
    38 CRYPT_ID_MD5 = 1
       
    39 CRYPT_ID_BLF = '2a'
       
    40 CRYPT_ID_SHA256 = 5
       
    41 CRYPT_ID_SHA512 = 6
       
    42 CRYPT_SALT_LEN = 2
       
    43 CRYPT_BLF_ROUNDS_MIN = 4
       
    44 CRYPT_BLF_ROUNDS_MAX = 31
       
    45 CRYPT_BLF_SALT_LEN = 22
       
    46 CRYPT_MD5_SALT_LEN = 8
       
    47 CRYPT_SHA2_ROUNDS_DEFAULT = 5000
       
    48 CRYPT_SHA2_ROUNDS_MIN = 1000
       
    49 CRYPT_SHA2_ROUNDS_MAX = 999999999
       
    50 CRYPT_SHA2_SALT_LEN = 16
       
    51 SALTED_ALGO_SALT_LEN = 4
       
    52 
       
    53 
       
    54 _ = lambda msg: msg
       
    55 cfg_dget = lambda option: None
       
    56 _sys_rand = SystemRandom()
       
    57 _choice = _sys_rand.choice
       
    58 _get_salt = lambda s_len: ''.join(_choice(SALTCHARS) for x in xrange(s_len))
       
    59 
       
    60 
       
    61 def _dovecotpw(password, scheme, encoding):
       
    62     """Communicates with dovecotpw (Dovecot 2.0: `doveadm pw`) and returns
       
    63     the hashed password: {scheme[.encoding]}hash
       
    64     """
       
    65     if encoding:
       
    66         scheme = '.'.join((scheme, encoding))
       
    67     cmd_args = [cfg_dget('bin.dovecotpw'), '-s', scheme, '-p',
       
    68                 get_unicode(password)]
       
    69     if cfg_dget('misc.dovecot_version') >= 0x20000a01:
       
    70         cmd_args.insert(1, 'pw')
       
    71     process = Popen(cmd_args, stdout=PIPE, stderr=PIPE)
       
    72     stdout, stderr = process.communicate()
       
    73     if process.returncode:
       
    74         raise VMMError(stderr.strip(), VMM_ERROR)
       
    75     hashed = stdout.strip()
       
    76     if not hashed.startswith('{%s}' % scheme):
       
    77         raise VMMError('Unexpected result from %s: %s' %
       
    78                        (cfg_dget('bin.dovecotpw'), hashed), VMM_ERROR)
       
    79     return hashed
       
    80 
       
    81 
       
    82 def _md4_new():
       
    83     """Returns an new MD4-hash object if supported by the hashlib or
       
    84     provided by PyCrypto - other `None`.
       
    85     """
       
    86     try:
       
    87         return hashlib.new('md4')
       
    88     except ValueError, err:
       
    89         if str(err) == 'unsupported hash type':
       
    90             if not COMPAT:
       
    91                 try:
       
    92                     from Crypto.Hash import MD4
       
    93                     return MD4.new()
       
    94                 except ImportError:
       
    95                     return None
       
    96         else:
       
    97             raise
       
    98 
       
    99 
       
   100 def _sha256_new(data=''):
       
   101     """Returns a new sha256 object from the hashlib.
       
   102 
       
   103     Returns `None` if the PyCrypto in pycompat.hashlib is too old."""
       
   104     if not COMPAT:
       
   105         return hashlib.sha256(data)
       
   106     try:
       
   107         return hashlib.new('sha256', data)
       
   108     except ValueError, err:
       
   109         if str(err) == 'unsupported hash type':
       
   110             return None
       
   111         else:
       
   112             raise
       
   113 
       
   114 
       
   115 def _format_digest(digest, scheme, encoding):
       
   116     """Formats the arguments to a string: {scheme[.encoding]}digest."""
       
   117     if not encoding:
       
   118         return '{%s}%s' % (scheme, digest)
       
   119     return '{%s.%s}%s' % (scheme, encoding, digest)
       
   120 
       
   121 
       
   122 def _clear_hash(password, scheme, encoding):
       
   123     """Generates a (encoded) CLEARTEXT/PLAIN 'hash'."""
       
   124     if encoding:
       
   125         if encoding == 'HEX':
       
   126             password = password.encode('hex')
       
   127         else:
       
   128             password = password.encode('base64').replace('\n', '')
       
   129         return _format_digest(password, scheme, encoding)
       
   130     return get_unicode('{%s}%s' % (scheme, password))
       
   131 
       
   132 
       
   133 def _get_crypt_blowfish_salt():
       
   134     """Generates a salt for Blowfish crypt."""
       
   135     rounds = cfg_dget('misc.crypt_blowfish_rounds')
       
   136     if rounds < CRYPT_BLF_ROUNDS_MIN:
       
   137         rounds = CRYPT_BLF_ROUNDS_MIN
       
   138     elif rounds > CRYPT_BLF_ROUNDS_MAX:
       
   139         rounds = CRYPT_BLF_ROUNDS_MAX
       
   140     return '$%s$%02d$%s' % (CRYPT_ID_BLF, rounds,
       
   141                             _get_salt(CRYPT_BLF_SALT_LEN))
       
   142 
       
   143 
       
   144 def _get_crypt_sha2_salt(crypt_id):
       
   145     """Generates a salt for crypt using the SHA-256 or SHA-512 encryption
       
   146     method.
       
   147     *crypt_id* must be either `5` (SHA-256) or `6` (SHA-512).
       
   148     """
       
   149     assert crypt_id in (CRYPT_ID_SHA256, CRYPT_ID_SHA512), 'invalid crypt ' \
       
   150            'id: %r' % crypt_id
       
   151     if crypt_id is CRYPT_ID_SHA512:
       
   152         rounds = cfg_dget('misc.crypt_sha512_rounds')
       
   153     else:
       
   154         rounds = cfg_dget('misc.crypt_sha256_rounds')
       
   155     if rounds < CRYPT_SHA2_ROUNDS_MIN:
       
   156         rounds = CRYPT_SHA2_ROUNDS_MIN
       
   157     elif rounds > CRYPT_SHA2_ROUNDS_MAX:
       
   158         rounds = CRYPT_SHA2_ROUNDS_MAX
       
   159     if rounds == CRYPT_SHA2_ROUNDS_DEFAULT:
       
   160         return '$%d$%s' % (crypt_id, _get_salt(CRYPT_SHA2_SALT_LEN))
       
   161     return '$%d$rounds=%d$%s' % (crypt_id, rounds,
       
   162                                  _get_salt(CRYPT_SHA2_SALT_LEN))
       
   163 
       
   164 
       
   165 def _crypt_hash(password, scheme, encoding):
       
   166     """Generates (encoded) CRYPT/MD5/{BLF,MD5,SHA{256,512}}-CRYPT hashes."""
       
   167     if scheme == 'CRYPT':
       
   168         salt = _get_salt(CRYPT_SALT_LEN)
       
   169     elif scheme == 'BLF-CRYPT':
       
   170         salt = _get_crypt_blowfish_salt()
       
   171     elif scheme in ('MD5-CRYPT', 'MD5'):
       
   172         salt = '$%d$%s' % (CRYPT_ID_MD5, _get_salt(CRYPT_MD5_SALT_LEN))
       
   173     elif scheme == 'SHA256-CRYPT':
       
   174         salt = _get_crypt_sha2_salt(CRYPT_ID_SHA256)
       
   175     else:
       
   176         salt = _get_crypt_sha2_salt(CRYPT_ID_SHA512)
       
   177     encrypted = crypt(password, salt)
       
   178     if encoding:
       
   179         if encoding == 'HEX':
       
   180             encrypted = encrypted.encode('hex')
       
   181         else:
       
   182             encrypted = encrypted.encode('base64').replace('\n', '')
       
   183     if scheme in ('BLF-CRYPT', 'SHA256-CRYPT', 'SHA512-CRYPT') and \
       
   184        cfg_dget('misc.dovecot_version') < 0x20000b06:
       
   185         scheme = 'CRYPT'
       
   186     return _format_digest(encrypted, scheme, encoding)
       
   187 
       
   188 
       
   189 def _md4_hash(password, scheme, encoding):
       
   190     """Generates encoded PLAIN-MD4 hashes."""
       
   191     md4 = _md4_new()
       
   192     if md4:
       
   193         md4.update(password)
       
   194         if encoding in DEFAULT_HEX:
       
   195             digest = md4.hexdigest()
       
   196         else:
       
   197             digest = md4.digest().encode('base64').rstrip()
       
   198         return _format_digest(digest, scheme, encoding)
       
   199     return _dovecotpw(password, scheme, encoding)
       
   200 
       
   201 
       
   202 def _md5_hash(password, scheme, encoding, user=None):
       
   203     """Generates DIGEST-MD5 aka PLAIN-MD5 and LDAP-MD5 hashes."""
       
   204     md5 = hashlib.md5()
       
   205     if scheme == 'DIGEST-MD5':
       
   206         #  Prior to Dovecot v1.1.12/v1.2.beta2 there was a problem with a
       
   207         #  empty auth_realms setting in dovecot.conf and user@domain.tld
       
   208         #  usernames. So we have to generate different hashes for different
       
   209         #  versions. See also:
       
   210         #       http://dovecot.org/list/dovecot-news/2009-March/000103.html
       
   211         #       http://hg.dovecot.org/dovecot-1.1/rev/2b0043ba89ae
       
   212         if cfg_dget('misc.dovecot_version') >= 0x1010cf00:
       
   213             md5.update('%s:%s:' % (user.localpart, user.domainname))
       
   214         else:
       
   215             md5.update('%s::' % user)
       
   216     md5.update(password)
       
   217     if (scheme in ('PLAIN-MD5', 'DIGEST-MD5') and encoding in DEFAULT_HEX) or \
       
   218        (scheme == 'LDAP-MD5' and encoding == 'HEX'):
       
   219         digest = md5.hexdigest()
       
   220     else:
       
   221         digest = md5.digest().encode('base64').rstrip()
       
   222     return _format_digest(digest, scheme, encoding)
       
   223 
       
   224 
       
   225 def _ntlm_hash(password, scheme, encoding):
       
   226     """Generates NTLM hashes."""
       
   227     md4 = _md4_new()
       
   228     if md4:
       
   229         password = ''.join('%s\x00' % c for c in password)
       
   230         md4.update(password)
       
   231         if encoding in DEFAULT_HEX:
       
   232             digest = md4.hexdigest()
       
   233         else:
       
   234             digest = md4.digest().encode('base64').rstrip()
       
   235         return _format_digest(digest, scheme, encoding)
       
   236     return _dovecotpw(password, scheme, encoding)
       
   237 
       
   238 
       
   239 def _sha1_hash(password, scheme, encoding):
       
   240     """Generates SHA1 aka SHA hashes."""
       
   241     sha1 = hashlib.sha1(password)
       
   242     if encoding in DEFAULT_B64:
       
   243         digest = sha1.digest().encode('base64').rstrip()
       
   244     else:
       
   245         digest = sha1.hexdigest()
       
   246     return _format_digest(digest, scheme, encoding)
       
   247 
       
   248 
       
   249 def _sha256_hash(password, scheme, encoding):
       
   250     """Generates SHA256 hashes."""
       
   251     sha256 = _sha256_new(password)
       
   252     if sha256:
       
   253         if encoding in DEFAULT_B64:
       
   254             digest = sha256.digest().encode('base64').rstrip()
       
   255         else:
       
   256             digest = sha256.hexdigest()
       
   257         return _format_digest(digest, scheme, encoding)
       
   258     return _dovecotpw(password, scheme, encoding)
       
   259 
       
   260 
       
   261 def _sha512_hash(password, scheme, encoding):
       
   262     """Generates SHA512 hashes."""
       
   263     if not COMPAT:
       
   264         sha512 = hashlib.sha512(password)
       
   265         if encoding in DEFAULT_B64:
       
   266             digest = sha512.digest().encode('base64').replace('\n', '')
       
   267         else:
       
   268             digest = sha512.hexdigest()
       
   269         return _format_digest(digest, scheme, encoding)
       
   270     return _dovecotpw(password, scheme, encoding)
       
   271 
       
   272 
       
   273 def _smd5_hash(password, scheme, encoding):
       
   274     """Generates SMD5 (salted PLAIN-MD5) hashes."""
       
   275     md5 = hashlib.md5(password)
       
   276     salt = _get_salt(SALTED_ALGO_SALT_LEN)
       
   277     md5.update(salt)
       
   278     if encoding in DEFAULT_B64:
       
   279         digest = (md5.digest() + salt).encode('base64').rstrip()
       
   280     else:
       
   281         digest = md5.hexdigest() + salt.encode('hex')
       
   282     return _format_digest(digest, scheme, encoding)
       
   283 
       
   284 
       
   285 def _ssha1_hash(password, scheme, encoding):
       
   286     """Generates SSHA (salted SHA/SHA1) hashes."""
       
   287     sha1 = hashlib.sha1(password)
       
   288     salt = _get_salt(SALTED_ALGO_SALT_LEN)
       
   289     sha1.update(salt)
       
   290     if encoding in DEFAULT_B64:
       
   291         digest = (sha1.digest() + salt).encode('base64').rstrip()
       
   292     else:
       
   293         digest = sha1.hexdigest() + salt.encode('hex')
       
   294     return _format_digest(digest, scheme, encoding)
       
   295 
       
   296 
       
   297 def _ssha256_hash(password, scheme, encoding):
       
   298     """Generates SSHA256 (salted SHA256) hashes."""
       
   299     sha256 = _sha256_new(password)
       
   300     if sha256:
       
   301         salt = _get_salt(SALTED_ALGO_SALT_LEN)
       
   302         sha256.update(salt)
       
   303         if encoding in DEFAULT_B64:
       
   304             digest = (sha256.digest() + salt).encode('base64').rstrip()
       
   305         else:
       
   306             digest = sha256.hexdigest() + salt.encode('hex')
       
   307         return _format_digest(digest, scheme, encoding)
       
   308     return _dovecotpw(password, scheme, encoding)
       
   309 
       
   310 
       
   311 def _ssha512_hash(password, scheme, encoding):
       
   312     """Generates SSHA512 (salted SHA512) hashes."""
       
   313     if not COMPAT:
       
   314         salt = _get_salt(SALTED_ALGO_SALT_LEN)
       
   315         sha512 = hashlib.sha512(password + salt)
       
   316         if encoding in DEFAULT_B64:
       
   317             digest = (sha512.digest() + salt).encode('base64').replace('\n',
       
   318                                                                        '')
       
   319         else:
       
   320             digest = sha512.hexdigest() + salt.encode('hex')
       
   321         return _format_digest(digest, scheme, encoding)
       
   322     return _dovecotpw(password, scheme, encoding)
       
   323 
       
   324 _scheme_info = {
       
   325     'CLEARTEXT': (_clear_hash, 0x10000f00),
       
   326     'CRAM-MD5': (_dovecotpw, 0x10000f00),
       
   327     'CRYPT': (_crypt_hash, 0x10000f00),
       
   328     'DIGEST-MD5': (_md5_hash, 0x10000f00),
       
   329     'HMAC-MD5': (_dovecotpw, 0x10000f00),
       
   330     'LANMAN': (_dovecotpw, 0x10000f00),
       
   331     'LDAP-MD5': (_md5_hash, 0x10000f00),
       
   332     'MD5': (_crypt_hash, 0x10000f00),
       
   333     'MD5-CRYPT': (_crypt_hash, 0x10000f00),
       
   334     'NTLM': (_ntlm_hash, 0x10000f00),
       
   335     'OTP': (_dovecotpw, 0x10100a01),
       
   336     'PLAIN': (_clear_hash, 0x10000f00),
       
   337     'PLAIN-MD4': (_md4_hash, 0x10000f00),
       
   338     'PLAIN-MD5': (_md5_hash, 0x10000f00),
       
   339     'RPA': (_dovecotpw, 0x10000f00),
       
   340     'SHA': (_sha1_hash, 0x10000f00),
       
   341     'SHA1': (_sha1_hash, 0x10000f00),
       
   342     'SHA256': (_sha256_hash, 0x10100a01),
       
   343     'SHA512': (_sha512_hash, 0x20000b03),
       
   344     'SKEY': (_dovecotpw, 0x10100a01),
       
   345     'SMD5': (_smd5_hash, 0x10000f00),
       
   346     'SSHA': (_ssha1_hash, 0x10000f00),
       
   347     'SSHA256': (_ssha256_hash, 0x10200a04),
       
   348     'SSHA512': (_ssha512_hash, 0x20000b03),
       
   349 }
       
   350 
       
   351 
       
   352 def list_schemes():
       
   353     """Returns the tuple (schemes, encodings).
       
   354 
       
   355     `schemes` is an iterator for all supported password schemes (depends on
       
   356     the used Dovecot version and features of the libc).
       
   357     `encodings` is a tuple with all usable encoding suffixes. The tuple may
       
   358     be empty.
       
   359     """
       
   360     dcv = cfg_dget('misc.dovecot_version')
       
   361     schemes = (k for (k, v) in _scheme_info.iteritems() if v[1] <= dcv)
       
   362     if dcv >= 0x10100a01:
       
   363         encodings = ('.B64', '.BASE64', '.HEX')
       
   364     else:
       
   365         encodings = ()
       
   366     return schemes, encodings
       
   367 
       
   368 
       
   369 def verify_scheme(scheme):
       
   370     """Checks if the password scheme *scheme* is known and supported by the
       
   371     configured `misc.dovecot_version`.
       
   372 
       
   373     The *scheme* maybe a password scheme's name (e.g.: 'PLAIN') or a scheme
       
   374     name with a encoding suffix (e.g. 'PLAIN.BASE64').  If the scheme is
       
   375     known and supported by the used Dovecot version,
       
   376     a tuple ``(scheme, encoding)`` will be returned.
       
   377     The `encoding` in the tuple may be `None`.
       
   378 
       
   379     Raises a `VMMError` if the password scheme:
       
   380       * is unknown
       
   381       * depends on a newer Dovecot version
       
   382       * has a unknown encoding suffix
       
   383     """
       
   384     assert isinstance(scheme, basestring), 'Not a str/unicode: %r' % scheme
       
   385     scheme_encoding = scheme.upper().split('.')
       
   386     scheme = scheme_encoding[0]
       
   387     if scheme not in _scheme_info:
       
   388         raise VMMError(_(u"Unsupported password scheme: '%s'") % scheme,
       
   389                        VMM_ERROR)
       
   390     if cfg_dget('misc.dovecot_version') < _scheme_info[scheme][1]:
       
   391         raise VMMError(_(u"The password scheme '%(scheme)s' requires Dovecot "
       
   392                          u">= v%(version)s.") % {'scheme': scheme,
       
   393                        'version': version_str(_scheme_info[scheme][1])},
       
   394                        VMM_ERROR)
       
   395     if len(scheme_encoding) > 1:
       
   396         if cfg_dget('misc.dovecot_version') < 0x10100a01:
       
   397             raise VMMError(_(u'Encoding suffixes for password schemes require '
       
   398                              u'Dovecot >= v1.1.alpha1.'), VMM_ERROR)
       
   399         if scheme_encoding[1] not in ('B64', 'BASE64', 'HEX'):
       
   400             raise VMMError(_(u"Unsupported password encoding: '%s'") %
       
   401                            scheme_encoding[1], VMM_ERROR)
       
   402         encoding = scheme_encoding[1]
       
   403     else:
       
   404         encoding = None
       
   405     return scheme, encoding
       
   406 
       
   407 
       
   408 def pwhash(password, scheme=None, user=None):
       
   409     """Generates a password hash from the plain text *password* string.
       
   410 
       
   411     If no *scheme* is given the password scheme from the configuration will
       
   412     be used for the hash generation.  When 'DIGEST-MD5' is used as scheme,
       
   413     also an EmailAddress instance must be given as *user* argument.
       
   414     """
       
   415     if not isinstance(password, basestring):
       
   416         raise TypeError('Password is not a string: %r' % password)
       
   417     if isinstance(password, unicode):
       
   418         password = password.encode(ENCODING)
       
   419     password = password.strip()
       
   420     if not password:
       
   421         raise ValueError("Could not accept empty password.")
       
   422     if scheme is None:
       
   423         scheme = cfg_dget('misc.password_scheme')
       
   424     scheme, encoding = verify_scheme(scheme)
       
   425     if scheme == 'DIGEST-MD5':
       
   426         assert isinstance(user, EmailAddress)
       
   427         return _md5_hash(password, scheme, encoding, user)
       
   428     return _scheme_info[scheme][0](password, scheme, encoding)
       
   429 
       
   430 
       
   431 def randompw():
       
   432     """Generates a plain text random password.
       
   433 
       
   434     The length of the password can be configured in the ``vmm.cfg``
       
   435     (account.password_length).
       
   436     """
       
   437     pw_len = cfg_dget('account.password_length')
       
   438     if pw_len < 8:
       
   439         pw_len = 8
       
   440     return ''.join(_sys_rand.sample(PASSWDCHARS, pw_len))
       
   441 
       
   442 
       
   443 def _test_crypt_algorithms():
       
   444     """Check for Blowfish/SHA-256/SHA-512 support in crypt.crypt()."""
       
   445     _blowfish = '$2a$04$0123456789abcdefABCDE.N.drYX5yIAL1LkTaaZotW3yI0hQhZru'
       
   446     _sha256 = '$5$rounds=1000$0123456789abcdef$K/DksR0DT01hGc8g/kt9McEgrbFMKi\
       
   447 9qrb1jehe7hn4'
       
   448     _sha512 = '$6$rounds=1000$0123456789abcdef$ZIAd5WqfyLkpvsVCVUU1GrvqaZTqvh\
       
   449 JoouxdSqJO71l9Ld3tVrfOatEjarhghvEYADkq//LpDnTeO90tcbtHR1'
       
   450 
       
   451     if crypt('08/15!test~4711', '$2a$04$0123456789abcdefABCDEF$') == _blowfish:
       
   452         _scheme_info['BLF-CRYPT'] = (_crypt_hash, 0x10000f00)
       
   453     if crypt('08/15!test~4711', '$5$rounds=1000$0123456789abcdef$') == _sha256:
       
   454         _scheme_info['SHA256-CRYPT'] = (_crypt_hash, 0x10000f00)
       
   455     if crypt('08/15!test~4711', '$6$rounds=1000$0123456789abcdef$') == _sha512:
       
   456         _scheme_info['SHA512-CRYPT'] = (_crypt_hash, 0x10000f00)
       
   457 
       
   458 _test_crypt_algorithms()
       
   459 del _, cfg_dget, _test_crypt_algorithms