Code

Add new config-option 'password_pbkdf2_default_rounds' in 'main' section
[roundup.git] / roundup / password.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
18 # $Id: password.py,v 1.15 2005-12-25 15:38:40 a1s Exp $
20 """Password handling (encoding, decoding).
21 """
22 __docformat__ = 'restructuredtext'
24 import re, string, random
25 from base64 import b64encode, b64decode
26 from roundup.anypy.hashlib_ import md5, sha1
27 try:
28     import crypt
29 except ImportError:
30     crypt = None
32 _bempty = ""
33 _bjoin = _bempty.join
35 def getrandbytes(count):
36     return _bjoin(chr(random.randint(0,255)) for i in xrange(count))
38 #NOTE: PBKDF2 hash is using this variant of base64 to minimize encoding size,
39 #      and have charset that's compatible w/ unix crypt variants
40 def h64encode(data):
41     """encode using variant of base64"""
42     return b64encode(data, "./").strip("=\n")
44 def h64decode(data):
45     """decode using variant of base64"""
46     off = len(data) % 4
47     if off == 0:
48         return b64decode(data, "./")
49     elif off == 1:
50         raise ValueError("invalid bas64 input")
51     elif off == 2:
52         return b64decode(data + "==", "./")
53     else:
54         return b64decode(data + "=", "./")
56 try:
57     from M2Crypto.EVP import pbkdf2 as _pbkdf2
58 except ImportError:
59     #no m2crypto - make our own pbkdf2 function
60     from struct import pack
61     from hmac import HMAC
62     try:
63         from hashlib import sha1
64     except ImportError:
65         from sha import new as sha1
67     def xor_bytes(left, right):
68         "perform bitwise-xor of two byte-strings"
69         return _bjoin(chr(ord(l) ^ ord(r)) for l, r in zip(left, right))
71     def _pbkdf2(password, salt, rounds, keylen):
72         digest_size = 20 # sha1 generates 20-byte blocks
73         total_blocks = int((keylen+digest_size-1)/digest_size)
74         hmac_template = HMAC(password, None, sha1)
75         out = _bempty
76         for i in xrange(1, total_blocks+1):
77             hmac = hmac_template.copy()
78             hmac.update(salt + pack(">L",i))
79             block = tmp = hmac.digest()
80             for j in xrange(rounds-1):
81                 hmac = hmac_template.copy()
82                 hmac.update(tmp)
83                 tmp = hmac.digest()
84                 #TODO: need to speed up this call
85                 block = xor_bytes(block, tmp)
86             out += block
87         return out[:keylen]
89 def pbkdf2(password, salt, rounds, keylen):
90     """pkcs#5 password-based key derivation v2.0
92     :arg password: passphrase to use to generate key (if unicode, converted to utf-8)
93     :arg salt: salt string to use when generating key (if unicode, converted to utf-8)
94     :param rounds: number of rounds to use to generate key
95     :arg keylen: number of bytes to generate
97     If M2Crypto is present, uses it's implementation as backend.
99     :returns:
100         raw bytes of generated key
101     """
102     if isinstance(password, unicode):
103         password = password.encode("utf-8")
104     if isinstance(salt, unicode):
105         salt = salt.encode("utf-8")
106     if keylen > 40:
107         #NOTE: pbkdf2 allows up to (2**31-1)*20 bytes,
108         # but m2crypto has issues on some platforms above 40,
109         # and such sizes aren't needed for a password hash anyways...
110         raise ValueError, "key length too large"
111     if rounds < 1:
112         raise ValueError, "rounds must be positive number"
113     return _pbkdf2(password, salt, rounds, keylen)
115 class PasswordValueError(ValueError):
116     """ The password value is not valid """
117     pass
119 def pbkdf2_unpack(pbkdf2):
120     """ unpack pbkdf2 encrypted password into parts,
121         assume it has format "{rounds}${salt}${digest}
122     """
123     if isinstance(pbkdf2, unicode):
124         pbkdf2 = pbkdf2.encode("ascii")
125     try:
126         rounds, salt, digest = pbkdf2.split("$")
127     except ValueError:
128         raise PasswordValueError, "invalid PBKDF2 hash (wrong number of separators)"
129     if rounds.startswith("0"):
130         raise PasswordValueError, "invalid PBKDF2 hash (zero-padded rounds)"
131     try:
132         rounds = int(rounds)
133     except ValueError:
134         raise PasswordValueError, "invalid PBKDF2 hash (invalid rounds)"
135     raw_salt = h64decode(salt)
136     return rounds, salt, raw_salt, digest
138 def encodePassword(plaintext, scheme, other=None, config=None):
139     """Encrypt the plaintext password.
140     """
141     if plaintext is None:
142         plaintext = ""
143     if scheme == "PBKDF2":
144         if other:
145             rounds, salt, raw_salt, digest = pbkdf2_unpack(other)
146         else:
147             raw_salt = getrandbytes(20)
148             salt = h64encode(raw_salt)
149             if config:
150                 rounds = config.PASSWORD_PBKDF2_DEFAULT_ROUNDS
151             else:
152                 rounds = 10000
153         if rounds < 1000:
154             raise PasswordValueError, "invalid PBKDF2 hash (rounds too low)"
155         raw_digest = pbkdf2(plaintext, raw_salt, rounds, 20)
156         return "%d$%s$%s" % (rounds, salt, h64encode(raw_digest))
157     elif scheme == 'SHA':
158         s = sha1(plaintext).hexdigest()
159     elif scheme == 'MD5':
160         s = md5(plaintext).hexdigest()
161     elif scheme == 'crypt' and crypt is not None:
162         if other is not None:
163             salt = other
164         else:
165             saltchars = './0123456789'+string.letters
166             salt = random.choice(saltchars) + random.choice(saltchars)
167         s = crypt.crypt(plaintext, salt)
168     elif scheme == 'plaintext':
169         s = plaintext
170     else:
171         raise PasswordValueError, 'unknown encryption scheme %r'%scheme
172     return s
174 def generatePassword(length=8):
175     chars = string.letters+string.digits
176     return ''.join([random.choice(chars) for x in range(length)])
178 class JournalPassword:
179     """ Password dummy instance intended for journal operation.
180         We do not store passwords in the journal any longer.  The dummy
181         version only reads the encryption scheme from the given
182         encrypted password.
183     """
184     default_scheme = 'PBKDF2'        # new encryptions use this scheme
185     pwre = re.compile(r'{(\w+)}(.+)')
187     def __init__ (self, encrypted=''):
188         if isinstance(encrypted, self.__class__):
189             self.scheme = encrypted.scheme or self.default_scheme
190         else:
191             m = self.pwre.match(encrypted)
192             if m:
193                 self.scheme = m.group(1)
194             else:
195                 self.scheme = self.default_scheme
196         self.password = ''
198     def dummystr(self):
199         """ return dummy string to store in journal
200             - reports scheme, but nothing else
201         """
202         return "{%s}*encrypted*" % (self.scheme,)
204     __str__ = dummystr
206     def __cmp__(self, other):
207         """Compare this password against another password."""
208         # check to see if we're comparing instances
209         if isinstance(other, self.__class__):
210             if self.scheme != other.scheme:
211                 return cmp(self.scheme, other.scheme)
212             return cmp(self.password, other.password)
214         # assume password is plaintext
215         if self.password is None:
216             raise ValueError, 'Password not set'
217         return cmp(self.password, encodePassword(other, self.scheme,
218             self.password or None))
220 class Password(JournalPassword):
221     """The class encapsulates a Password property type value in the database.
223     The encoding of the password is one if None, 'SHA', 'MD5' or 'plaintext'.
224     The encodePassword function is used to actually encode the password from
225     plaintext. The None encoding is used in legacy databases where no
226     encoding scheme is identified.
228     The scheme is stored with the encoded data in the database:
229         {scheme}data
231     Example usage:
232     >>> p = Password('sekrit')
233     >>> p == 'sekrit'
234     1
235     >>> p != 'not sekrit'
236     1
237     >>> 'sekrit' == p
238     1
239     >>> 'not sekrit' != p
240     1
241     """
242     #TODO: code to migrate from old password schemes.
244     deprecated_schemes = ["SHA", "MD5", "crypt", "plaintext"]
245     known_schemes = ["PBKDF2"] + deprecated_schemes
247     def __init__(self, plaintext=None, scheme=None, encrypted=None, strict=False, config=None):
248         """Call setPassword if plaintext is not None."""
249         if scheme is None:
250             scheme = self.default_scheme
251         if plaintext is not None:
252             self.setPassword (plaintext, scheme, config=config)
253         elif encrypted is not None:
254             self.unpack(encrypted, scheme, strict=strict, config=config)
255         else:
256             self.scheme = self.default_scheme
257             self.password = None
258             self.plaintext = None
260     def needs_migration(self):
261         """ Password has insecure scheme or other insecure parameters
262             and needs migration to new password scheme
263         """
264         if self.scheme in self.deprecated_schemes:
265             return True
266         rounds, salt, raw_salt, digest = pbkdf2_unpack(self.password)
267         if rounds < 1000:
268             return True
269         return False
271     def unpack(self, encrypted, scheme=None, strict=False, config=None):
272         """Set the password info from the scheme:<encryted info> string
273            (the inverse of __str__)
274         """
275         m = self.pwre.match(encrypted)
276         if m:
277             self.scheme = m.group(1)
278             self.password = m.group(2)
279             self.plaintext = None
280         else:
281             # currently plaintext - encrypt
282             self.setPassword(encrypted, scheme, config=config)
283         if strict and self.scheme not in self.known_schemes:
284             raise PasswordValueError, "unknown encryption scheme: %r" % (self.scheme,)
286     def setPassword(self, plaintext, scheme=None, config=None):
287         """Sets encrypts plaintext."""
288         if scheme is None:
289             scheme = self.default_scheme
290         self.scheme = scheme
291         self.password = encodePassword(plaintext, scheme, config=config)
292         self.plaintext = plaintext
294     def __str__(self):
295         """Stringify the encrypted password for database storage."""
296         if self.password is None:
297             raise ValueError, 'Password not set'
298         return '{%s}%s'%(self.scheme, self.password)
300 def test():
301     # SHA
302     p = Password('sekrit')
303     assert p == 'sekrit'
304     assert p != 'not sekrit'
305     assert 'sekrit' == p
306     assert 'not sekrit' != p
308     # MD5
309     p = Password('sekrit', 'MD5')
310     assert p == 'sekrit'
311     assert p != 'not sekrit'
312     assert 'sekrit' == p
313     assert 'not sekrit' != p
315     # crypt
316     p = Password('sekrit', 'crypt')
317     assert p == 'sekrit'
318     assert p != 'not sekrit'
319     assert 'sekrit' == p
320     assert 'not sekrit' != p
322     # PBKDF2 - low level function
323     from binascii import unhexlify
324     k = pbkdf2("password", "ATHENA.MIT.EDUraeburn", 1200, 32)
325     assert k == unhexlify("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13")
327     # PBKDF2 - hash function
328     h = "5000$7BvbBq.EZzz/O0HuwX3iP.nAG3s$g3oPnFFaga2BJaX5PoPRljl4XIE"
329     assert encodePassword("sekrit", "PBKDF2", h) == h
331     # PBKDF2 - high level integration
332     p = Password('sekrit', 'PBKDF2')
333     assert p == 'sekrit'
334     assert p != 'not sekrit'
335     assert 'sekrit' == p
336     assert 'not sekrit' != p
338 if __name__ == '__main__':
339     test()
341 # vim: set filetype=python sts=4 sw=4 et si :