Commits

Donald Stufft committed 81bf034

Include a migration path for moving legacy users to a stronger hash

* Includes a method for hashing the sha1 passwords with bcrypt to
increase their security
* bcrypt_sha1 will upgrade to standard bcrypt as per usual with
passlib
* Provides a script that migrates 20 users at a time to bcrypt_sha1

Migration script was modified from one written by Giovanni Bajo

Comments (0)

Files changed (4)

config.ini.template

 [passlib]
 ; The first listed schemed will automatically be the default, see passlib
 ;   documentation for a full list of options.
-schemes = bcrypt, hex_sha1
+schemes = bcrypt, bcrypt_sha1, hex_sha1
 
 [logging]
 file =
 from urlparse import urlsplit, urlunsplit
 
 from passlib.context import CryptContext
+from passlib.registry import register_crypt_handler_path
+
+
+# Register our legacy password handler
+register_crypt_handler_path("bcrypt_sha1", "legacy_passwords")
 
 
 class Config:

legacy_passwords.py

+import base64
+import hashlib
+
+import passlib.exc as exc
+import passlib.utils.handlers as uh
+
+from passlib.registry import get_crypt_handler
+from passlib.utils import to_unicode
+from passlib.utils.compat import uascii_to_str
+
+
+passlib_bcrypt = get_crypt_handler("bcrypt")
+
+
+class bcrypt_sha1(uh.StaticHandler):
+
+    name = "bcrypt_sha1"
+    _hash_prefix = u"$bcrypt_sha1$"
+
+    def _calc_checksum(self, secret):
+        # Hash the secret with sha1 first
+        secret = hashlib.sha1(secret).hexdigest()
+
+        # Hash it with bcrypt
+        return passlib_bcrypt.encrypt(secret)
+
+    def to_string(self):
+        assert self.checksum is not None
+        return uascii_to_str(self._hash_prefix + base64.b64encode(self.checksum))
+
+    @classmethod
+    def from_string(cls, hash, **context):
+        # default from_string() which strips optional prefix,
+        # and passes rest unchanged as checksum value.
+        hash = to_unicode(hash, "ascii", "hash")
+        hash = cls._norm_hash(hash)
+        # could enable this for extra strictness
+        ##pat = cls._hash_regex
+        ##if pat and pat.match(hash) is None:
+        ##    raise ValueError("not a valid %s hash" % (cls.name,))
+        prefix = cls._hash_prefix
+        if prefix:
+            if hash.startswith(prefix):
+                hash = hash[len(prefix):]
+            else:
+                raise exc.InvalidHashError(cls)
+
+        # Decode the base64 stored actual hash
+        hash = unicode(base64.b64decode(hash))
+
+        return cls(checksum=hash, **context)
+
+    @classmethod
+    def verify(cls, secret, hash, **context):
+        # NOTE: classes with multiple checksum encodings should either
+        # override this method, or ensure that from_string() / _norm_checksum()
+        # ensures .checksum always uses a single canonical representation.
+        uh.validate_secret(secret)
+        self = cls.from_string(hash, **context)
+        chk = self.checksum
+        if chk is None:
+            raise exc.MissingDigestError(cls)
+
+        # Actually use the verify from passlib_bcrypt after hashing the secret
+        #   with sha1
+        secret = hashlib.sha1(secret).hexdigest()
+        return passlib_bcrypt.verify(secret, chk)

tools/upgradepw.py

+#!/usr/bin/python
+import base64
+import os
+import sys
+
+# Workaround current bug in docutils:
+# http://permalink.gmane.org/gmane.text.docutils.devel/6324
+import docutils.utils
+
+
+root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(root)
+
+import config
+import store
+import passlib.registry
+
+bcrypt = passlib.registry.get_crypt_handler("bcrypt")
+bcrypt_sha1 = passlib.registry.get_crypt_handler("bcrypt_sha1")
+
+cfg = config.Config(os.path.join(root, "config.ini"))
+st = store.Store(cfg)
+
+print "Migrating passwords to bcrypt_sha1 from unsalted sha1....",
+
+st.open()
+for i, u in enumerate(st.get_users()):
+    user = st.get_user(u['name'])
+    # basic sanity check to allow it to run concurrent with users accessing
+    if len(user['password']) == 40 and "$" not in user["password"]:
+        # Hash the existing sha1 password with bcrypt
+        bcrypted = bcrypt.encrypt(user["password"])
+
+        # Base64 encode the bcrypted password so that it's just a blob of data
+        encoded = base64.b64encode(bcrypted)
+
+        st.setpasswd(user['name'], bcrypt_sha1._hash_prefix + encoded,
+                hashed=True,
+            )
+
+    # Commit every 20 users
+    if not i % 20:
+        st.commit()
+        st.open()
+
+st.commit()
+st.close()
+
+print "[ok]"