|
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 |