Immediately migrating existing passwords to bcrypt
Security cannot afford to be “eventually consistent”.
That’s Geoffroy Couprie’s response to my last post about upgrading to bcrypt. He’s right, of course. The solution he proposed is the same one kcen suggested on Reddit:
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.
Setup
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 password_hash
.
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
Migrate
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_column
avoids 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.