Skip to content

Instantly share code, notes, and snippets.

@cagerton
Last active May 12, 2021 23:36
Show Gist options
  • Save cagerton/5485241 to your computer and use it in GitHub Desktop.
Save cagerton/5485241 to your computer and use it in GitHub Desktop.
On storing password hashes

Upgrade sha1 => scrypt without user logging in/pw reset:

μBackground:

We hash passwords with one-way hash functions to reduce the impact of password database compromise. Passwords hashed with salted SHA or MD5 can ofen be brute forced. Newer password hashhing techniques like Scrypt/Bcrypt/PBKDF2 add computational work to make brute forcing prohibitively expensive.

Problem:

Your service already has users with salted sha1 password hashes. Since you don't have their original passwords you can't upgrade to a modern password hash without getting each user to log in and enter their password.

You have (salt,sha(salt+password)), you want (salt, scrypt(salt,password))

Solution:

Instead, feed the existing salted sha1 into scrypt: scrypt(salt,sha1(salt+password)).

To verify a password you'll have to compute a sha1 hash and then an scrypt hash. This is simple to implement, it protects the passwords from brute force attacks, and it does not require action on behalf of your users.


Pro-Tip: If your salt is short or not unique, please create a second salt for the scrypt hashes.
Pro-Tip: Store a password version with each password hash. This will make it easy to incrementally upgrade passwords.
Pro-Tip: Don't forget to purge all sha1's when you're done.

Proof of concept code follows:

import ssl, scrypt
from base64 import b64encode as b64e
from hmac import compare_digest
from hashlib import sha1

class User(object):
    """Here is toy "User" class with sha1(salt+password) hashes.
        Your current password scheme probably looks something like this."""
    def __init__(self, name, password):
        self.name = name
        self.set_password(password)

    def set_password(self, password):
        salt = str(b64e(ssl.RAND_bytes(9)))
        self.enc_pw = (salt, self.hash_password(salt,password),) # (salt, hash,)

    def check_password(self, password):
        salt, real_hsh = self.enc_pw
        return hmac.compare_digest(real_hsh, self.hash_password(salt, password))

    @staticmethod
    def hash_password(salt, password):
        return b64e(sha1((salt+password).encode('utf-8')).digest())

And the new user class:

class SafeUser(User):
    """Proof of concept for how you can upgrade to scrypt without forcing a password reset."""
    def __init__(self, name, password=None):
        self.name = name
        if(password):
            self.set_password(password)

    @staticmethod
    def hash_password(salt, password, sha=None):
        sha = sha or User.hash_password(salt, password)
        return b64e(scrypt.hash(sha, salt))

    @classmethod
    def upgrade(cls, old):
      salt, sha = old.enc_pw
      u = cls(old.name)
      u.enc_pw = (salt, u.hash_password(salt, None, sha),)
      return u

In action:

# demo
u = User("bob", "horrible-pw")
print("Bob's salt,password:", u.enc_pw)

u2  = SafeUser.upgrade(u)
print("Bob's new encoded password:", u2.enc_pw)
Bob's salt,password: ("b'eFXvv3YHGg9n'", b'4PTlKhAuW6ZAc6vEB6ujoq4Bq6U=')
Bob's new encoded password: ("b'eFXvv3YHGg9n'", b'sDr+X3vDbj2hESc9z5kESDnu5HCwTNqNALjcgJdFZ+CLzE06PL6slx8o0pYePpAsLZtBdIN4/TOGJlcytefz0g==')
(u2salt, u2hsh) = u2.enc_pw
pw = "horrible-pw"
u3 = SafeUser("joe", pw)
assert u.check_password("horrible-pw"), "Bob can log in"
assert not u.check_password("other"), "Everyone can log in"
assert u2.check_password(pw), "Bob can still log in"
assert not u2.check_password("other-password"), "everyone can log in"
assert u2hsh==b64e(scrypt.hash(b64e(sha1((u2salt+pw).encode('utf-8')).digest()),u2salt))
assert u3.check_password(pw), "Bob can still log in"
assert not u3.check_password("other-password"), "everyone can log in"

Crazy Idea: Have the client do the work:

Since it is computationally expensive to verify a modern password hash, it may be possible for an attacker to overwhelm your server with repeated login attempts (possibly from many ip addresses).

Consider a new password hashing scheme like this:

pw_salt = hmac("seed-for:example.com", username)
pw_hash = sha2(scrypt(passsword,pw_salt))
  • Salt derived from hmac of site key and username:
    • This makes it hard to compute rainbow tables or see if two users have the same password.
    • If you've already computed a salt, you could send it the the client. That gets messy fast.
    • You don't need user specific information for the javascript work engine.
  • Scrypt:
    • if an attacker steals your db, it's hard to brute force plaintext passwords
  • Sha2 wrapper - computed on server:
    • an attacker can't use the stolen db credentials to log on.
  • Bonus:
    • During normal conditions you can still run scrypt on the server.

Note: This will suck for old browsers and mobile devices. You should only ship work to the client as a last resort when you're being hammered with invalid password attempts.

Demo for the client side code:

(function(){
  "use strict";
  
  var h = document.createElement("script"),
      s = document.createElement("script");
  h.src="https://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/hmac-sha256.js";
  s.src="https://raw.github.com/tonyg/js-scrypt/6dd22c033a6d645cdef8244d332ea231c2f0a73a/browser/scrypt.js";
  s.onload=load;
  h.onload=load;
  var pending = 2;
  document.body.appendChild(h);
  document.body.appendChild(s);

  function hash_user(site_seed, username, password){
     var salt = CryptoJS.HmacSHA256(site_seed,username).toString();
     var pass_enc = scrypt.encode_utf8(password),
        salt_enc = scrypt.encode_utf8(salt),
        result = scrypt.crypto_scrypt(pass_enc, salt_enc, 1<<14, 8, 1, 64);
    return btoa(scrypt.decode_latin1(result));
  }

  function load(){
      pending -=1;
      if(pending==0){
          var t0 = Date.now(); 
          console.log("Hash: ",hash_user("seed-for:example.com","bob","another-terrible-password"));
          console.log("time: ",Date.now()-t0,"ms");
      }
  }
})();

This was in Chrome; it was also decent in firefox. Safari took ~30 seconds.

Hash:  GYt7KIu4xoQujLeape3BwfEyUPLUR+flTFp+iNj4ZLk/lmbAgxfys8Fy/u83lcOKKUNCJoQoq3t+SBM+4KI3hw==
time:  1168 ms
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment