Immediately Migrating Existing Passwords to bcrypt
Security cannot afford to be “eventually consistent”.
I solved this problem at a company I joined a couple years ago. […] I created a new database column, bcrypted all of those hashes, then dropped the original password column from the database.
At first, and for no good reason, I disliked the idea of bcrypting a password hash. It just felt weird. I asked around and everyone agreed: weird, but no real objections.
So I decided to do a little research. After all, combining cryptographic primitives in the wrong way is an easy way to do cryptography wrong. I eventually found a question on the cryptography Stack Exchange that assuaged my fears. It said that “the overall idea is a sound migration strategy”, which was good enough for me.
Assume that everything’s the same as before, except that the passwords are hashed. It doesn’t matter how they’re hashed or if a salt is used. Let’s keep it simple by calling the hash function
digest and storing the result in
def digest(password) password.hash.to_s end # user.password_hash = digest(password)
Setting the password is now slightly more complicated than before. Instead of simply using the plain text password as the input to bcrypt, we have to use the password hash. This adds a layer of indirection but allows us to migrate without knowing the original passwords.
def bcrypt=(new_password) @bcrypt = self.bcrypt_hash = Password.create(digest(new_password)) end
Similarly, checking passwords now requires comparing against the hashed password.
def self.authenticate(username, password) return unless user = find_by_username(username) password_hash = digest(password) if user.bcrypt? user if user.bcrypt == password_hash elsif user.password_hash == password_hash user.bcrypt = password user.save! user end end
After doing that, the only thing left to do is the actual migration. Be warned: this will take a long time. Although the exact time depends on your machine, you can get an estimate using the benchmark module.
Benchmark.measure do 100.times do BCrypt::Password.create('secret') end end.total # => 7.45
The migration itself is pretty straightforward. It has three moving parts:
Grab unmigrated users in chunks until none are left. This allows the migration to pick up from where it left off it it gets interrupted. In addition, users migrated through the authenticate method won’t throw a wrench in the works.
Calculate a bcrypt hash for each user using the password hash as input. This part will take a while, since bcrypt is designed to be slow.
Save the bcrypt hash to the database. Using
update_columnavoids triggering callbacks or running validators.
class BcryptMigration < ActiveRecord::Migration class User < ActiveRecord::Base; end def up loop do users = User.select([:id, :password_hash]). where(:bcrypt_hash => nil).order(:id).limit(100) break if users.empty? users.each do |user| bcrypt_hash = BCrypt::Password.create(user['password_hash']) user.update_column(:bcrypt_hash, bcrypt_hash) end end end end
Although you could remove
password_hash entirely in this migration, it’s better to do that as a separate migration after this one finishes. That way if anything goes wrong with the switch to bcrypt you can fall back to the old method.