Index: repoze/who/plugins/tests/test_sql.py
===================================================================
--- repoze/who/plugins/tests/test_sql.py	(revision 4767)
+++ repoze/who/plugins/tests/test_sql.py	(working copy)
@@ -80,18 +80,128 @@
             from sha import new as sha1
         return sha1(clear).hexdigest()
 
-    def test_shaprefix_success(self):
+    def _get_sha_b64_digest(self, clear='password'):
+        try:
+            from hashlib import sha1
+        except ImportError:
+            from sha import new as sha1
+        from base64 import urlsafe_b64encode
+        return urlsafe_b64encode(sha1(clear).digest())
+
+    def _get_ssha_b64_digest(self, clear='password'):
+        try:
+            from hashlib import sha1
+        except ImportError:
+            from sha import new as sha1
+        from os import urandom
+        from base64 import urlsafe_b64encode
+        salt = urandom(4)
+        hasher = sha1(clear)
+        hasher.update(salt)
+        return "{SSHA}%s" % urlsafe_b64encode(hasher.digest() + salt)
+
+    def _get_ssha256_b64_digest(self, clear='password'):
+        try:
+            from hashlib import sha256
+        except ImportError:
+            from Crypto.Hash.SHA256 import new as sha256
+        from os import urandom
+        from base64 import urlsafe_b64encode
+        salt = urandom(4)
+        hasher = sha256(clear)
+        hasher.update(salt)
+        return "{SSHA256}%s" % urlsafe_b64encode(hasher.digest() + salt)
+
+    def _get_bcrypt_digest(self, clear='password'):
+        import bcrypt
+        return "{CRYPT}%s" % bcrypt.hashpw(clear, bcrypt.gensalt())
+
+    def test_shahex_prefix_success(self):
         stored = '{SHA}' +  self._get_sha_hex_digest()
         compare = self._getFUT()
         result = compare('password', stored)
         self.assertEqual(result, True)
 
-    def test_shaprefix_fail(self):
+    def test_shahex_prefix_fail(self):
         stored = '{SHA}' + self._get_sha_hex_digest()
         compare = self._getFUT()
         result = compare('notpassword', stored)
         self.assertEqual(result, False)
 
+    def test_shab64_prefix_success(self):
+        stored = '{SHA}' +  self._get_sha_b64_digest()
+        compare = self._getFUT()
+        result = compare('password', stored)
+        self.assertEqual(result, True)
+
+    def test_shab64_prefix_fail(self):
+        stored = '{SHA}' + self._get_sha_b64_digest()
+        compare = self._getFUT()
+        result = compare('notpassword', stored)
+        self.assertEqual(result, False)
+
+    def test_ssha_prefix_success(self):
+        stored = self._get_ssha_b64_digest()
+        compare = self._getFUT()
+        result = compare('password', stored)
+        self.assertEqual(result, True)
+
+    def test_ssha_prefix_fail(self):
+        stored = self._get_ssha_b64_digest()
+        compare = self._getFUT()
+        result = compare('notpassword', stored)
+        self.assertEqual(result, False)
+
+    def test_ssha256_prefix_success(self):
+        compare = self._getFUT()
+        try:
+            from hashlib import sha256
+        except ImportError:
+            try:
+                from Crypto.Hash.SHA256 import new as sha256
+            except ImportError:
+                self.assertRaises(NotImplementedError, compare, 'password', 'unimportant')
+                return
+        stored = self._get_ssha256_b64_digest()
+        result = compare('password', stored)
+        self.assertEqual(result, True)
+
+    def test_ssha256_prefix_fail(self):
+        compare = self._getFUT()
+        try:
+            from hashlib import sha256
+        except ImportError:
+            try:
+                from Crypto.Hash.SHA256 import new as sha256
+            except ImportError:
+                self.assertRaises(NotImplementedError, compare, 'notpassword', 'unimportant')
+                return
+        stored = self._get_ssha256_b64_digest()
+        result = compare('notpassword', stored)
+        self.assertEqual(result, False)
+
+    def test_bcrypt_prefix_success(self):
+        compare = self._getFUT()
+        try:
+            import bcrypt
+        except ImportError:
+            self.assertRaises(NotImplementedError, compare, 'password', '{CRYPT}unimportant')
+            return
+        stored = self._get_bcrypt_digest()
+        result = compare('password', stored)
+        self.assertEqual(result, True)
+
+    def test_bcrypt_prefix_fail(self):
+        compare = self._getFUT()
+        try:
+            import bcrypt
+        except ImportError:
+            self.assertRaises(NotImplementedError, compare, 'notpassword', '{CRYPT}unimportant')
+            return
+        stored = self._get_bcrypt_digest()
+        result = compare('notpassword', stored)
+        self.assertEqual(result, False)
+
     def test_noprefix_success(self):
         stored = 'password'
         compare = self._getFUT()
@@ -104,6 +214,55 @@
         result = compare('notpassword', stored)
         self.assertEqual(result, False)
 
+    def test_nounicode_stored(self):
+        stored = u'password'
+        compare = self._getFUT()
+        self.assertRaises(AssertionError, compare, 'password', stored)
+
+    def test_nounicode_input(self):
+        stored = 'password'
+        compare = self._getFUT()
+        self.assertRaises(AssertionError, compare, u'unicodepassword', stored)
+
+class TestDefaultPasswordHash(unittest.TestCase):
+
+    def _getFUT(self):
+        from repoze.who.plugins.sql import default_password_hash
+        return default_password_hash
+
+    def test_scheme_ssha(self):
+        hasher = self._getFUT()
+        password = 'password'
+        digest = hasher(password, scheme='SSHA')
+        self.failUnless(digest.startswith('{SSHA}'))
+
+    def test_scheme_unimplemented(self):
+        hasher = self._getFUT()
+        password = 'password'
+        self.assertRaises(NotImplementedError, hasher, password, 'noscheme')
+
+class TestDefaultPasswordRoundTrip(unittest.TestCase):
+
+    def _roundtrip(self, scheme, cleartext_password='password'):
+        from repoze.who.plugins.sql import default_password_compare, default_password_hash
+        return default_password_compare(cleartext_password, default_password_hash(cleartext_password, scheme=scheme))
+        
+    def test_best_scheme(self):
+        self.assertTrue(self._roundtrip('BESTAVAILABLE'))
+
+    def test_bcrypt_scheme(self):
+        try:
+            import bcrypt
+            self.assertTrue(self._roundtrip('CRYPT'))
+        except ImportError:
+            self.assertRaises(NotImplementedError, self._roundtrip, 'CRYPT')
+
+    def test_ssha256_scheme(self):
+        self.assertTrue(self._roundtrip('SSHA256'))
+
+    def test_ssha_scheme(self):
+        self.assertTrue(self._roundtrip('SSHA'))
+
 class TestSQLMetadataProviderPlugin(unittest.TestCase):
 
     def _getTargetClass(self):
Index: repoze/who/plugins/sql.py
===================================================================
--- repoze/who/plugins/sql.py	(revision 4767)
+++ repoze/who/plugins/sql.py	(working copy)
@@ -4,25 +4,236 @@
 from repoze.who.interfaces import IMetadataProvider
 
 def default_password_compare(cleartext_password, stored_password_hash):
+    # Hashing functions work on bytes, not strings, so while unicode passwords
+    # with only ascii characters work, it could blow up.  We'll catch that in
+    # all cases.
+    assert isinstance(cleartext_password, str)
+    assert isinstance(stored_password_hash, str)
+
+    # This functin assumes that stored hashes starting with {scheme} are hashed
+    # passwords, and all other passwords are cleartext.  Because of this
+    # assumption, cleartext passwords that begin with {scheme} will generate
+    # errors for users.  In this case, it is recommended to migrate the
+    # cleartext passwords to bcrypt.
+    scheme = None
+    if stored_password_hash.startswith('{'):
+        # Look for the password scheme.
+        try:
+            endtoken = stored_password_hash.index('}')
+            scheme = stored_password_hash[1:endtoken].upper()
+            stored_password_hash = stored_password_hash[endtoken+1:]
+        except ValueError:
+            scheme = 'CLEAR'
+    else:
+        scheme = 'CLEAR'
+
+    from base64 import urlsafe_b64encode, urlsafe_b64decode
+
+    # The support is getting better and better.  We now support five schemes
+    # for hashing, Salted Blowfish Crypt (bcrypt), Salted SHA256, Salted SHA-1,
+    # standard SHA-1, and cleartext passwords.  The general encoding scheme
+    # follows RFC 2307 standard for storage of encrypted passwords.  The
+    # standard format looks like this: {scheme}encryptedpassword where the
+    # encrypted password is base64 encoded in a url safe way.  In the case of
+    # bcrypt, encrypted password is slightly different, as it is of the form:
+    # {CRYPT}$2a$<2 digit complexity>$<22 bytes salt><31 bytes hash>  The salt
+    # and hash are base64 encoded as in other schemes.
+    # Caveats: SHA256 hashing requires Python 2.5 or pycrypto
+    #          bcrypt hashing requires bcrypt module.
+
+    if scheme == 'CRYPT':
+        try:
+            from bcrypt import bcrypt
+        except ImportError:
+            # We blow up so that Sysadmins can detect the error quicker and get
+            # the problem fixed.
+            raise NotImplementedError("Unable to load bcrypt module for Blowfish hashes")
+        return stored_password_hash == bcrypt.hashpw(cleartext_password, stored_password_hash)
+
+    # The salted SHA hashes work the same.  The only difference is how to find
+    # the suitable hash module.
+    if scheme == 'SSHA256':
+        try:
+            from hashlib import sha256
+        except ImportError:
+            try:
+                from Crypto.Hash.SHA256 import new as sha256
+            except ImportError:
+            # We blow up so that Sysadmins can detect the error quicker and get
+            # the problem fixed.
+                raise NotImplementedError("Unable to load suitable module for SHA256 hashes")
+        try:
+            hash_bytes = urlsafe_b64decode(stored_password_hash)
+        except TypeError:
+            # This will happen if database is using unschemed cleartext
+            # passwords, and the cleartext password is a bad encoding of a
+            # schemed hash
+            raise ValueError("Invalid password hash.")
+        # SHA 256 is 256-bits of output (32 bytes)
+        salt = hash_bytes[32:]
+        hasher = sha256(cleartext_password)
+        hasher.update(salt)
+        return stored_password_hash == urlsafe_b64encode(hasher.digest() + salt)
+
+    if scheme == 'SSHA':
+        try:
+            from hashlib import sha1
+        except ImportError:
+            try:
+                from sha import new as sha1
+            except ImportError:
+                return False
+        try:
+            hash_bytes = urlsafe_b64decode(stored_password_hash)
+        except TypeError:
+            # This will happen if database is using unschemed cleartext
+            # passwords, and the cleartext password is a bad encoding of a
+            # schemed hash
+            raise ValueError("Invalid password hash.")
+        # SHA-1 is 160-bits of output (20 bytes)
+        salt = hash_bytes[20:]
+        hasher = sha1(cleartext_password)
+        hasher.update(salt)
+        return stored_password_hash == urlsafe_b64encode(hasher.digest() + salt)
+
+    if scheme == 'SHA':
+        try:
+            from hashlib import sha1
+        except ImportError:
+            try:
+                from sha import new as sha1
+            except ImportError:
+                # We blow up so that Sysadmins can detect the error quicker and
+                # get the problem fixed.
+                raise NotImplementedError("Unable to find hashing algorithm SHA-1 or stronger.")
+
+        hasher = sha1(cleartext_password)
+        # We need to support the legacy, hex format for existing hashes.
+        # Luckily, we can unambiguously tell the difference, as SHA-1 hashes
+        # always end with '=' (an invalid hex character) when base64 encoded.
+        if stored_password_hash.endswith('='):
+            computed_hash = urlsafe_b64encode(hasher.digest())
+        else:
+            computed_hash = hasher.hexdigest()
+        return stored_password_hash == computed_hash
+
+    if scheme == 'CLEAR':
+        return stored_password_hash == cleartext_password
+    # While we support reading of unsalted SHA-1 and cleartext passwords for
+    # legacy databases support, we won't generate these unsecure formats.
+
+    # Oops, unsupported scheme...
+    # We blow up so that Sysadmins can detect the error quicker and get the
+    # problem fixed.
+    raise NotImplementedError("Unrecognized Hash Scheme: %s" % scheme)
+
+def default_password_hash(cleartext_password, scheme='BESTAVAILABLE'):
+    # Hashing functions work on bytes, not strings, so while unicode passwords
+    # with only ascii characters work, it could blow up.  We'll catch that in
+    # all cases.
+    assert isinstance(cleartext_password, str)
+
     try:
-        from hashlib import sha1
-    except ImportError: # Python < 2.5 #pragma NO COVERAGE
-        from sha import new as sha1    #pragma NO COVERAGE
+        scheme = scheme.upper()
+    except AttributeError:
+        pass
+    from base64 import urlsafe_b64encode
+    from os import urandom
 
-    # the stored password is stored as '{SHA}<SHA hexdigest>'.
-    # or as a cleartext password (no {SHA} prefix)
+    # The support is getting better and better.  We now support three salted
+    # schemes for hashing, Salted Blowfish Crypt (bcrypt), Salted SHA256,
+    # and Salted SHA-1. While the compare function can ready unsalted SHA-1 and
+    # cleartext passwords, we don't support generating them.  The general
+    # encoding scheme follows RFC 2307 standard for storage of encrypted
+    # passwords.  The standard format looks like this:
+    # {scheme}encryptedpassword where the encrypted password is base64 encoded
+    # in a url safe way.  In the case of bcrypt, encrypted password is slightly
+    # different, as it is of the form:
+    # {CRYPT}$2a$<2 digit complexity>$<22 bytes salt><31 bytes hash>  The salt
+    # and hash are base64 encoded as in other schemes.
+    # Caveats: SHA256 hashing requires Python 2.5 or pycrypto
+    #          bcrypt hashing requires bcrypt module.
 
-    if stored_password_hash.startswith('{SHA}'):
-        stored_password_hash = stored_password_hash[5:]
-        digest = sha1(cleartext_password).hexdigest()
-    else:
-        digest = cleartext_password
-        
-    if stored_password_hash == digest:
-        return True
+    if scheme == 'BESTAVAILABLE':
+        # Since bcrypt is the strongest cryptographically, we'll default to it
+        # if available.
+        try:
+            from bcrypt import bcrypt
+        except ImportError:
+            bcrypt = None
+        if bcrypt:
+            return "{CRYPT}%s" % bcrypt.hashpw(cleartext_password, bcrypt.gensalt())
 
-    return False
+        # Next up is the SHA family, we'll try SHA256 which is available in
+        # Python >= 2.5 or with the pycrypto module.  Without that, we'll fall
+        # back to SHA-1.
+        try:
+            from hashlib import sha256 as hashalgorithm
+            scheme = 'SSHA256'
+        except ImportError: # Python < 2.5 #pragma NO COVERAGE
+            try:
+                # On Python < 2.5, we pull sha256 from pycrypto if it's installed.
+                from Crypto.Hash.SHA256 import new as hashalgorithm
+                scheme = 'SSHA256'
+            except ImportError:
+                # If we couldn't import sha256 above, we know we can't pull
+                # sha1 from the same module, so we'll try to pull from the
+                # older sha module.
+                try:
+                    from sha import new as hashalgorithm
+                    scheme = 'SSHA'
+                except ImportError:
+                    raise NotImplementedError("Unable to find hashing algorithm SHA-1 or stronger.")
 
+        # The algorithm is the same for the entire SHA family, pretty easy.
+        salt = urandom(4)
+        hasher = hashalgorithm(cleartext_password)
+        hasher.update(salt)
+        return "{%s}%s" % (scheme, urlsafe_b64encode(hasher.digest() + salt))
+
+    # Now that the ugly default case is out of the way, we handle the explicit
+    # cases.
+    if scheme == 'CRYPT':
+        try:
+            from bcrypt import bcrypt
+        except ImportError:
+            raise NotImplementedError("Unable to load bcrypt module for Blowfish hashes")
+        return "{CRYPT}%s" % bcrypt.hashpw(password, bcrypt.gensalt())
+
+    # The salted SHA hashes work the same.  The only difference is how to find
+    # the suitable hash module.
+    if scheme == 'SSHA256':
+        try:
+            from hashlib import sha256
+        except ImportError:
+            try:
+                from Crypto.Hash.SHA256 import new as sha256
+            except ImportError:
+                raise NotImplementedError("Unable to load suitable module for SHA256 hashes")
+        salt = urandom(4)
+        hasher = sha256(cleartext_password)
+        hasher.update(salt)
+        return "{SSHA256}%s" % urlsafe_b64encode(hasher.digest() + salt)
+
+    if scheme == 'SSHA':
+        try:
+            from hashlib import sha1
+        except ImportError:
+            try:
+                from sha import new as sha1
+            except ImportError:
+                raise NotImplementedError("Unable to load suitable module for SHA-1 hashes")
+        salt = urandom(4)
+        hasher = sha1(cleartext_password)
+        hasher.update(salt)
+        return "{SSHA}%s" % urlsafe_b64encode(hasher.digest() + salt)
+
+    # While we support reading of unsalted SHA-1 and cleartext passwords for
+    # legacy databases support, we won't generate these unsecure formats.
+
+    # Oops, unsupported scheme...
+    raise NotImplementedError("Unrecognized Hash Scheme: %s" % scheme)
+
 def make_psycopg_conn_factory(**kw):
     # convenience (I always seem to use Postgres)
     def conn_factory(): #pragma NO COVERAGE
Index: CHANGES.txt
===================================================================
--- CHANGES.txt	(revision 4767)
+++ CHANGES.txt	(working copy)
@@ -10,6 +10,18 @@
 
 - One-hundred percent unit test coverage.
 
+- Added 'default_password_hash' which provides a default hash implementation
+  that matches the default_password_compare.
+
+- Added salted SHA (SSHA) support to the default_password_compare.
+
+- Added salted SHA-256 (SSHA256) support to the default_password_compare.
+
+- Added blowfish (bcrypt) support to the default_password_compare.
+
+- Changed the default hash storage to use base64 encoding, which is more
+  standards compliant.  Older hex based storage is supported as well.
+
 1.0.13 (2009/4/24)
 ==================
 
