Skip to content

Commit c20f920

Browse files
author
Ralf Schlatterbeck
committed
Fix first part of Password handling security issue2550688
(thanks Joseph Myers for reporting and Eli Collins for fixing) Small change against original patch: We still accept plaintext passwords (in known_schemes) when parsing encrypted password (e.g. from database). This way existing databases with plaintext passwords continue to work (I don't know of any, this would need patching on the users side) and all regression tests pass.
1 parent 6ecd3cc commit c20f920

File tree

9 files changed

+159
-42
lines changed

9 files changed

+159
-42
lines changed

CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ Fixed:
7575
(Ralf Schlatterbeck)
7676
- Fixed bug in mailgw refactoring, patch issue2550697 (thanks Hubert
7777
Touvet)
78+
- Fix first part of Password handling security issue2550688 (thanks
79+
Joseph Myers for reporting and Eli Collins for fixing)
7880

7981
2010-10-08 1.4.16 (r4541)
8082

doc/acknowledgements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Titus Brown,
2222
Steve Byan,
2323
Brett Cannon,
2424
Godefroid Chapelle,
25+
Eli Collins,
2526
Roch'e Compaan,
2627
Wil Cooley,
2728
Joe Cooper,
@@ -92,6 +93,7 @@ Ulrik Mikaelsson,
9293
John Mitchell,
9394
Ramiro Morales,
9495
Toni Mueller,
96+
Joseph Myers,
9597
Stefan Niederhauser,
9698
Truls E. Næss,
9799
Bryce L Nordgren,

roundup/backends/back_anydbm.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -504,9 +504,7 @@ def unserialise(self, classname, node):
504504
elif isinstance(prop, hyperdb.Interval) and v is not None:
505505
d[k] = date.Interval(v)
506506
elif isinstance(prop, hyperdb.Password) and v is not None:
507-
p = password.Password()
508-
p.unpack(v)
509-
d[k] = p
507+
d[k] = password.Password(encrypted=v)
510508
else:
511509
d[k] = v
512510
return d
@@ -1744,7 +1742,7 @@ def _filter(self, search_matches, filterspec, proptree,
17441742
l.append((OTHER, k, [float(val) for val in v]))
17451743

17461744
filterspec = l
1747-
1745+
17481746
# now, find all the nodes that are active and pass filtering
17491747
matches = []
17501748
cldb = self.db.getclassdb(cn)
@@ -2028,9 +2026,7 @@ def import_list(self, propnames, proplist):
20282026
elif isinstance(prop, hyperdb.Interval):
20292027
value = date.Interval(value)
20302028
elif isinstance(prop, hyperdb.Password):
2031-
pwd = password.Password()
2032-
pwd.unpack(value)
2033-
value = pwd
2029+
value = password.Password(encrypted=value)
20342030
d[propname] = value
20352031

20362032
# get a new id if necessary

roundup/backends/rdbms_common.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2832,9 +2832,7 @@ def import_list(self, propnames, proplist):
28322832
elif isinstance(prop, hyperdb.Interval):
28332833
value = date.Interval(value)
28342834
elif isinstance(prop, hyperdb.Password):
2835-
pwd = password.Password()
2836-
pwd.unpack(value)
2837-
value = pwd
2835+
value = password.Password(encrypted=value)
28382836
elif isinstance(prop, String):
28392837
if isinstance(value, unicode):
28402838
value = value.encode('utf8')

roundup/hyperdb.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -72,24 +72,12 @@ class Password(_Type):
7272
def from_raw(self, value, **kw):
7373
if not value:
7474
return None
75-
m = password.Password.pwre.match(value)
76-
if m:
77-
# password is being given to us encrypted
78-
p = password.Password()
79-
p.scheme = m.group(1)
80-
if p.scheme not in 'SHA crypt plaintext'.split():
81-
raise HyperdbValueError, \
82-
('property %s: unknown encryption scheme %r') %\
83-
(kw['propname'], p.scheme)
84-
p.password = m.group(2)
85-
value = p
86-
else:
87-
try:
88-
value = password.Password(value)
89-
except password.PasswordValueError, message:
90-
raise HyperdbValueError, \
91-
_('property %s: %s')%(kw['propname'], message)
92-
return value
75+
try:
76+
return password.Password(encrypted=value, strict=True)
77+
except password.PasswordValueError, message:
78+
raise HyperdbValueError, \
79+
_('property %s: %s')%(kw['propname'], message)
80+
9381
def sort_repr (self, cls, val, name):
9482
if not val:
9583
return val
@@ -1307,9 +1295,7 @@ def import_journals(self, entries):
13071295
elif isinstance(prop, Interval):
13081296
value = date.Interval(value)
13091297
elif isinstance(prop, Password):
1310-
pwd = password.Password()
1311-
pwd.unpack(value)
1312-
value = pwd
1298+
value = password.Password(encrypted=value)
13131299
params[propname] = value
13141300
elif action == 'create' and params:
13151301
# old tracker with data stored in the create!
@@ -1337,7 +1323,7 @@ def get_roles(self, nodeid):
13371323

13381324
def has_role(self, nodeid, *roles):
13391325
'''See if this node has any roles that appear in roles.
1340-
1326+
13411327
For convenience reasons we take a list.
13421328
In standard schemas only a user has a roles property but
13431329
this may be different in customized schemas.

roundup/password.py

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,96 @@
2222
__docformat__ = 'restructuredtext'
2323

2424
import re, string, random
25+
from base64 import b64encode, b64decode
2526
from roundup.anypy.hashlib_ import md5, sha1
2627
try:
2728
import crypt
2829
except ImportError:
2930
crypt = None
3031

32+
_bempty = ""
33+
_bjoin = _bempty.join
34+
35+
def getrandbytes(count):
36+
return _bjoin(chr(random.randint(0,255)) for i in xrange(count))
37+
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")
43+
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 + "=", "./")
55+
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
66+
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))
70+
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]
88+
89+
def pbkdf2(password, salt, rounds, keylen):
90+
"""pkcs#5 password-based key derivation v2.0
91+
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
96+
97+
If M2Crypto is present, uses it's implementation as backend.
98+
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)
114+
31115
class PasswordValueError(ValueError):
32116
""" The password value is not valid """
33117
pass
@@ -37,7 +121,33 @@ def encodePassword(plaintext, scheme, other=None):
37121
"""
38122
if plaintext is None:
39123
plaintext = ""
40-
if scheme == 'SHA':
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':
41151
s = sha1(plaintext).hexdigest()
42152
elif scheme == 'MD5':
43153
s = md5(plaintext).hexdigest()
@@ -80,24 +190,26 @@ class Password:
80190
>>> 'not sekrit' != p
81191
1
82192
"""
193+
#TODO: code to migrate from old password schemes.
83194

84-
default_scheme = 'SHA' # new encryptions use this scheme
195+
default_scheme = 'PBKDF2' # new encryptions use this scheme
196+
known_schemes = [ "PBKDF2", "SHA", "MD5", "crypt", "plaintext" ]
85197
pwre = re.compile(r'{(\w+)}(.+)')
86198

87-
def __init__(self, plaintext=None, scheme=None, encrypted=None):
199+
def __init__(self, plaintext=None, scheme=None, encrypted=None, strict=False):
88200
"""Call setPassword if plaintext is not None."""
89201
if scheme is None:
90202
scheme = self.default_scheme
91203
if plaintext is not None:
92204
self.setPassword (plaintext, scheme)
93205
elif encrypted is not None:
94-
self.unpack(encrypted, scheme)
206+
self.unpack(encrypted, scheme, strict=strict)
95207
else:
96208
self.scheme = self.default_scheme
97209
self.password = None
98210
self.plaintext = None
99211

100-
def unpack(self, encrypted, scheme=None):
212+
def unpack(self, encrypted, scheme=None, strict=False):
101213
"""Set the password info from the scheme:<encryted info> string
102214
(the inverse of __str__)
103215
"""
@@ -109,6 +221,8 @@ def unpack(self, encrypted, scheme=None):
109221
else:
110222
# currently plaintext - encrypt
111223
self.setPassword(encrypted, scheme)
224+
if strict and self.scheme not in self.known_schemes:
225+
raise PasswordValueError, "unknown encryption scheme: %r" % (self.scheme,)
112226

113227
def setPassword(self, plaintext, scheme=None):
114228
"""Sets encrypts plaintext."""
@@ -160,6 +274,22 @@ def test():
160274
assert 'sekrit' == p
161275
assert 'not sekrit' != p
162276

277+
# PBKDF2 - low level function
278+
from binascii import unhexlify
279+
k = pbkdf2("password", "ATHENA.MIT.EDUraeburn", 1200, 32)
280+
assert k == unhexlify("5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13")
281+
282+
# PBKDF2 - hash function
283+
h = "5000$7BvbBq.EZzz/O0HuwX3iP.nAG3s$g3oPnFFaga2BJaX5PoPRljl4XIE"
284+
assert encodePassword("sekrit", "PBKDF2", h) == h
285+
286+
# PBKDF2 - high level integration
287+
p = Password('sekrit', 'PBKDF2')
288+
assert p == 'sekrit'
289+
assert p != 'not sekrit'
290+
assert 'sekrit' == p
291+
assert 'not sekrit' != p
292+
163293
if __name__ == '__main__':
164294
test()
165295

roundup/roundupdb.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,7 @@ def confirm_registration(self, otk):
103103
elif isinstance(proptype, hyperdb.Interval):
104104
props[propname] = date.Interval(value)
105105
elif isinstance(proptype, hyperdb.Password):
106-
props[propname] = password.Password()
107-
props[propname].unpack(value)
106+
props[propname] = password.Password(encrypted=value)
108107

109108
# tag new user creation with 'admin'
110109
self.journaltag = 'admin'
@@ -241,7 +240,7 @@ def good_recipient(userid):
241240
user or a user who has already seen the message.
242241
Also check permissions on the message if not a system
243242
message: A user must have view permission on content and
244-
files to be on the receiver list. We do *not* check the
243+
files to be on the receiver list. We do *not* check the
245244
author etc. for now.
246245
"""
247246
allowed = True

test/db_test_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
config.RDBMS_HOST = "localhost"
3636
config.RDBMS_USER = "rounduptest"
3737
config.RDBMS_PASSWORD = "rounduptest"
38-
#config.RDBMS_TEMPLATE = "template0"
38+
config.RDBMS_TEMPLATE = "template0"
3939
#config.logging = MockNull()
4040
# these TRACKER_WEB and MAIL_DOMAIN values are used in mailgw tests
4141
config.MAIL_DOMAIN = "your.tracker.email.domain.example"

test/test_security.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import os, unittest, shutil
2424

2525
from roundup import backends
26-
from roundup.password import Password
26+
import roundup.password
2727
from db_test_base import setupSchema, MyTestCase, config
2828

2929
class PermissionTest(MyTestCase):
@@ -233,6 +233,10 @@ def testTransitiveSearchPermissions(self):
233233
self.assertEquals(has(uimu, 'issue', 'messages.recipients'), 1)
234234
self.assertEquals(has(uimu, 'issue', 'messages.recipients.username'), 1)
235235

236+
# roundup.password has its own built-in test, call it.
237+
def test_password(self):
238+
roundup.password.test()
239+
236240
def test_suite():
237241
suite = unittest.TestSuite()
238242
suite.addTest(unittest.makeSuite(PermissionTest))

0 commit comments

Comments
 (0)