Code

Second patch from issue2550688 -- with some changes:
[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 encodePassword(plaintext, scheme, other=None):
120     """Encrypt the plaintext password.
121     """
122     if plaintext is None:
123         plaintext = ""
124     if scheme == "PBKDF2":
125         if other:
126             #assume it has format "{rounds}${salt}${digest}"
127             if isinstance(other, unicode):
128                 other = other.encode("ascii")
129             try:
130                 rounds, salt, digest = other.split("$")
131             except ValueError:
132                 raise PasswordValueError, "invalid PBKDF2 hash (wrong number of separators)"
133             if rounds.startswith("0"):
134                 raise PasswordValueError, "invalid PBKDF2 hash (zero-padded rounds)"
135             try:
136                 rounds = int(rounds)
137             except ValueError:
138                 raise PasswordValueError, "invalid PBKDF2 hash (invalid rounds)"
139             raw_salt = h64decode(salt)
140         else:
141             raw_salt = getrandbytes(20)
142             salt = h64encode(raw_salt)
143             #FIXME: find way to access config, so default rounds
144             # can be altered for faster/slower hosts via config.ini
145             rounds = 10000
146         if rounds < 1000:
147             raise PasswordValueError, "invalid PBKDF2 hash (rounds too low)"
148         raw_digest = pbkdf2(plaintext, raw_salt, rounds, 20)
149         return "%d$%s$%s" % (rounds, salt, h64encode(raw_digest))
150     elif scheme == 'SHA':
151         s = sha1(plaintext).hexdigest()
152     elif scheme == 'MD5':
153         s = md5(plaintext).hexdigest()
154     elif scheme == 'crypt' and crypt is not None:
155         if other is not None:
156             salt = other
157         else:
158             saltchars = './0123456789'+string.letters
159             salt = random.choice(saltchars) + random.choice(saltchars)
160         s = crypt.crypt(plaintext, salt)
161     elif scheme == 'plaintext':
162         s = plaintext
163     else:
164         raise PasswordValueError, 'unknown encryption scheme %r'%scheme
165     return s
167 def generatePassword(length=8):
168     chars = string.letters+string.digits
169     return ''.join([random.choice(chars) for x in range(length)])
171 class JournalPassword:
172     """ Password dummy instance intended for journal operation.
173         We do not store passwords in the journal any longer.  The dummy
174         version only reads the encryption scheme from the given
175         encrypted password.
176     """
177     default_scheme = 'PBKDF2'        # new encryptions use this scheme
178     pwre = re.compile(r'{(\w+)}(.+)')
180     def __init__ (self, encrypted=''):
181         if isinstance(encrypted, self.__class__):
182             self.scheme = encrypted.scheme or self.default_scheme
183         else:
184             m = self.pwre.match(encrypted)
185             if m:
186                 self.scheme = m.group(1)
187             else:
188                 self.scheme = self.default_scheme
189         self.password = ''
191     def dummystr(self):
192         """ return dummy string to store in journal
193             - reports scheme, but nothing else
194         """
195         return "{%s}*encrypted*" % (self.scheme,)
197     __str__ = dummystr
199     def __cmp__(self, other):
200         """Compare this password against another password."""
201         # check to see if we're comparing instances
202         if isinstance(other, self.__class__):
203             if self.scheme != other.scheme:
204                 return cmp(self.scheme, other.scheme)
205             return cmp(self.password, other.password)
207         # assume password is plaintext
208         if self.password is None:
209             raise ValueError, 'Password not set'
210         return cmp(self.password, encodePassword(other, self.scheme,
211             self.password or None))
213 class Password(JournalPassword):
214     """The class encapsulates a Password property type value in the database.
216     The encoding of the password is one if None, 'SHA', 'MD5' or 'plaintext'.
217     The encodePassword function is used to actually encode the password from
218     plaintext. The None encoding is used in legacy databases where no
219     encoding scheme is identified.
221     The scheme is stored with the encoded data in the database:
222         {scheme}data
224     Example usage:
225     >>> p = Password('sekrit')
226     >>> p == 'sekrit'
227     1
228     >>> p != 'not sekrit'
229     1
230     >>> 'sekrit' == p
231     1
232     >>> 'not sekrit' != p
233     1
234     """
235     #TODO: code to migrate from old password schemes.
237     known_schemes = [ "PBKDF2", "SHA", "MD5", "crypt", "plaintext" ]
239     def __init__(self, plaintext=None, scheme=None, encrypted=None, strict=False):
240         """Call setPassword if plaintext is not None."""
241         if scheme is None:
242             scheme = self.default_scheme
243         if plaintext is not None:
244             self.setPassword (plaintext, scheme)
245         elif encrypted is not None:
246             self.unpack(encrypted, scheme, strict=strict)
247         else:
248             self.scheme = self.default_scheme
249             self.password = None
250             self.plaintext = None
252     def unpack(self, encrypted, scheme=None, strict=False):
253         """Set the password info from the scheme:<encryted info> string
254            (the inverse of __str__)
255         """
256         m = self.pwre.match(encrypted)
257         if m:
258             self.scheme = m.group(1)
259             self.password = m.group(2)
260             self.plaintext = None
261         else:
262             # currently plaintext - encrypt
263             self.setPassword(encrypted, scheme)
264         if strict and self.scheme not in self.known_schemes:
265             raise PasswordValueError, "unknown encryption scheme: %r" % (self.scheme,)
267     def setPassword(self, plaintext, scheme=None):
268         """Sets encrypts plaintext."""
269         if scheme is None:
270             scheme = self.default_scheme
271         self.scheme = scheme
272         self.password = encodePassword(plaintext, scheme)
273         self.plaintext = plaintext
275     def __str__(self):
276         """Stringify the encrypted password for database storage."""
277         if self.password is None:
278             raise ValueError, 'Password not set'
279         return '{%s}%s'%(self.scheme, self.password)
281 def test():
282     # SHA
283     p = Password('sekrit')
284     assert p == 'sekrit'
285     assert p != 'not sekrit'
286     assert 'sekrit' == p
287     assert 'not sekrit' != p
289     # MD5
290     p = Password('sekrit', 'MD5')
291     assert p == 'sekrit'
292     assert p != 'not sekrit'
293     assert 'sekrit' == p
294     assert 'not sekrit' != p
296     # crypt
297     p = Password('sekrit', 'crypt')
298     assert p == 'sekrit'
299     assert p != 'not sekrit'
300     assert 'sekrit' == p
301     assert 'not sekrit' != p
303     # PBKDF2 - low level function
304     from binascii import unhexlify
305     k = pbkdf2("password", "ATHENA.MIT.EDUraeburn", 1200, 32)
306     assert k == unhexlify("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13")
308     # PBKDF2 - hash function
309     h = "5000$7BvbBq.EZzz/O0HuwX3iP.nAG3s$g3oPnFFaga2BJaX5PoPRljl4XIE"
310     assert encodePassword("sekrit", "PBKDF2", h) == h
312     # PBKDF2 - high level integration
313     p = Password('sekrit', 'PBKDF2')
314     assert p == 'sekrit'
315     assert p != 'not sekrit'
316     assert 'sekrit' == p
317     assert 'not sekrit' != p
319 if __name__ == '__main__':
320     test()
322 # vim: set filetype=python sts=4 sw=4 et si :