Code

python2.4 compatibility fix
[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, shamodule
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
63     def xor_bytes(left, right):
64         "perform bitwise-xor of two byte-strings"
65         return _bjoin(chr(ord(l) ^ ord(r)) for l, r in zip(left, right))
67     def _pbkdf2(password, salt, rounds, keylen):
68         digest_size = 20 # sha1 generates 20-byte blocks
69         total_blocks = int((keylen+digest_size-1)/digest_size)
70         hmac_template = HMAC(password, None, shamodule)
71         out = _bempty
72         for i in xrange(1, total_blocks+1):
73             hmac = hmac_template.copy()
74             hmac.update(salt + pack(">L",i))
75             block = tmp = hmac.digest()
76             for j in xrange(rounds-1):
77                 hmac = hmac_template.copy()
78                 hmac.update(tmp)
79                 tmp = hmac.digest()
80                 #TODO: need to speed up this call
81                 block = xor_bytes(block, tmp)
82             out += block
83         return out[:keylen]
85 def pbkdf2(password, salt, rounds, keylen):
86     """pkcs#5 password-based key derivation v2.0
88     :arg password: passphrase to use to generate key (if unicode, converted to utf-8)
89     :arg salt: salt string to use when generating key (if unicode, converted to utf-8)
90     :param rounds: number of rounds to use to generate key
91     :arg keylen: number of bytes to generate
93     If M2Crypto is present, uses it's implementation as backend.
95     :returns:
96         raw bytes of generated key
97     """
98     if isinstance(password, unicode):
99         password = password.encode("utf-8")
100     if isinstance(salt, unicode):
101         salt = salt.encode("utf-8")
102     if keylen > 40:
103         #NOTE: pbkdf2 allows up to (2**31-1)*20 bytes,
104         # but m2crypto has issues on some platforms above 40,
105         # and such sizes aren't needed for a password hash anyways...
106         raise ValueError, "key length too large"
107     if rounds < 1:
108         raise ValueError, "rounds must be positive number"
109     return _pbkdf2(password, salt, rounds, keylen)
111 class PasswordValueError(ValueError):
112     """ The password value is not valid """
113     pass
115 def pbkdf2_unpack(pbkdf2):
116     """ unpack pbkdf2 encrypted password into parts,
117         assume it has format "{rounds}${salt}${digest}
118     """
119     if isinstance(pbkdf2, unicode):
120         pbkdf2 = pbkdf2.encode("ascii")
121     try:
122         rounds, salt, digest = pbkdf2.split("$")
123     except ValueError:
124         raise PasswordValueError, "invalid PBKDF2 hash (wrong number of separators)"
125     if rounds.startswith("0"):
126         raise PasswordValueError, "invalid PBKDF2 hash (zero-padded rounds)"
127     try:
128         rounds = int(rounds)
129     except ValueError:
130         raise PasswordValueError, "invalid PBKDF2 hash (invalid rounds)"
131     raw_salt = h64decode(salt)
132     return rounds, salt, raw_salt, digest
134 def encodePassword(plaintext, scheme, other=None, config=None):
135     """Encrypt the plaintext password.
136     """
137     if plaintext is None:
138         plaintext = ""
139     if scheme == "PBKDF2":
140         if other:
141             rounds, salt, raw_salt, digest = pbkdf2_unpack(other)
142         else:
143             raw_salt = getrandbytes(20)
144             salt = h64encode(raw_salt)
145             if config:
146                 rounds = config.PASSWORD_PBKDF2_DEFAULT_ROUNDS
147             else:
148                 rounds = 10000
149         if rounds < 1000:
150             raise PasswordValueError, "invalid PBKDF2 hash (rounds too low)"
151         raw_digest = pbkdf2(plaintext, raw_salt, rounds, 20)
152         return "%d$%s$%s" % (rounds, salt, h64encode(raw_digest))
153     elif scheme == 'SHA':
154         s = sha1(plaintext).hexdigest()
155     elif scheme == 'MD5':
156         s = md5(plaintext).hexdigest()
157     elif scheme == 'crypt' and crypt is not None:
158         if other is not None:
159             salt = other
160         else:
161             saltchars = './0123456789'+string.letters
162             salt = random.choice(saltchars) + random.choice(saltchars)
163         s = crypt.crypt(plaintext, salt)
164     elif scheme == 'plaintext':
165         s = plaintext
166     else:
167         raise PasswordValueError, 'unknown encryption scheme %r'%scheme
168     return s
170 def generatePassword(length=8):
171     chars = string.letters+string.digits
172     return ''.join([random.choice(chars) for x in range(length)])
174 class JournalPassword:
175     """ Password dummy instance intended for journal operation.
176         We do not store passwords in the journal any longer.  The dummy
177         version only reads the encryption scheme from the given
178         encrypted password.
179     """
180     default_scheme = 'PBKDF2'        # new encryptions use this scheme
181     pwre = re.compile(r'{(\w+)}(.+)')
183     def __init__ (self, encrypted=''):
184         if isinstance(encrypted, self.__class__):
185             self.scheme = encrypted.scheme or self.default_scheme
186         else:
187             m = self.pwre.match(encrypted)
188             if m:
189                 self.scheme = m.group(1)
190             else:
191                 self.scheme = self.default_scheme
192         self.password = ''
194     def dummystr(self):
195         """ return dummy string to store in journal
196             - reports scheme, but nothing else
197         """
198         return "{%s}*encrypted*" % (self.scheme,)
200     __str__ = dummystr
202     def __cmp__(self, other):
203         """Compare this password against another password."""
204         # check to see if we're comparing instances
205         if isinstance(other, self.__class__):
206             if self.scheme != other.scheme:
207                 return cmp(self.scheme, other.scheme)
208             return cmp(self.password, other.password)
210         # assume password is plaintext
211         if self.password is None:
212             raise ValueError, 'Password not set'
213         return cmp(self.password, encodePassword(other, self.scheme,
214             self.password or None))
216 class Password(JournalPassword):
217     """The class encapsulates a Password property type value in the database.
219     The encoding of the password is one if None, 'SHA', 'MD5' or 'plaintext'.
220     The encodePassword function is used to actually encode the password from
221     plaintext. The None encoding is used in legacy databases where no
222     encoding scheme is identified.
224     The scheme is stored with the encoded data in the database:
225         {scheme}data
227     Example usage:
228     >>> p = Password('sekrit')
229     >>> p == 'sekrit'
230     1
231     >>> p != 'not sekrit'
232     1
233     >>> 'sekrit' == p
234     1
235     >>> 'not sekrit' != p
236     1
237     """
238     #TODO: code to migrate from old password schemes.
240     deprecated_schemes = ["SHA", "MD5", "crypt", "plaintext"]
241     known_schemes = ["PBKDF2"] + deprecated_schemes
243     def __init__(self, plaintext=None, scheme=None, encrypted=None, strict=False, config=None):
244         """Call setPassword if plaintext is not None."""
245         if scheme is None:
246             scheme = self.default_scheme
247         if plaintext is not None:
248             self.setPassword (plaintext, scheme, config=config)
249         elif encrypted is not None:
250             self.unpack(encrypted, scheme, strict=strict, config=config)
251         else:
252             self.scheme = self.default_scheme
253             self.password = None
254             self.plaintext = None
256     def needs_migration(self):
257         """ Password has insecure scheme or other insecure parameters
258             and needs migration to new password scheme
259         """
260         if self.scheme in self.deprecated_schemes:
261             return True
262         rounds, salt, raw_salt, digest = pbkdf2_unpack(self.password)
263         if rounds < 1000:
264             return True
265         return False
267     def unpack(self, encrypted, scheme=None, strict=False, config=None):
268         """Set the password info from the scheme:<encryted info> string
269            (the inverse of __str__)
270         """
271         m = self.pwre.match(encrypted)
272         if m:
273             self.scheme = m.group(1)
274             self.password = m.group(2)
275             self.plaintext = None
276         else:
277             # currently plaintext - encrypt
278             self.setPassword(encrypted, scheme, config=config)
279         if strict and self.scheme not in self.known_schemes:
280             raise PasswordValueError, "unknown encryption scheme: %r" % (self.scheme,)
282     def setPassword(self, plaintext, scheme=None, config=None):
283         """Sets encrypts plaintext."""
284         if scheme is None:
285             scheme = self.default_scheme
286         self.scheme = scheme
287         self.password = encodePassword(plaintext, scheme, config=config)
288         self.plaintext = plaintext
290     def __str__(self):
291         """Stringify the encrypted password for database storage."""
292         if self.password is None:
293             raise ValueError, 'Password not set'
294         return '{%s}%s'%(self.scheme, self.password)
296 def test():
297     # SHA
298     p = Password('sekrit')
299     assert p == 'sekrit'
300     assert p != 'not sekrit'
301     assert 'sekrit' == p
302     assert 'not sekrit' != p
304     # MD5
305     p = Password('sekrit', 'MD5')
306     assert p == 'sekrit'
307     assert p != 'not sekrit'
308     assert 'sekrit' == p
309     assert 'not sekrit' != p
311     # crypt
312     p = Password('sekrit', 'crypt')
313     assert p == 'sekrit'
314     assert p != 'not sekrit'
315     assert 'sekrit' == p
316     assert 'not sekrit' != p
318     # PBKDF2 - low level function
319     from binascii import unhexlify
320     k = pbkdf2("password", "ATHENA.MIT.EDUraeburn", 1200, 32)
321     assert k == unhexlify("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13")
323     # PBKDF2 - hash function
324     h = "5000$7BvbBq.EZzz/O0HuwX3iP.nAG3s$g3oPnFFaga2BJaX5PoPRljl4XIE"
325     assert encodePassword("sekrit", "PBKDF2", h) == h
327     # PBKDF2 - high level integration
328     p = Password('sekrit', 'PBKDF2')
329     assert p == 'sekrit'
330     assert p != 'not sekrit'
331     assert 'sekrit' == p
332     assert 'not sekrit' != p
334 if __name__ == '__main__':
335     test()
337 # vim: set filetype=python sts=4 sw=4 et si :