After Gawker, Please Hash Passwords with bcrypt
Just over a week ago Gawker Media discovered that its servers had been compromised, with a security breach across Gawker, Gizmodo, Lifehacker, Jezebel, io9, Jalopnik, Kotaku, Deadspin and Fleshbot. Their FAQ about the breach tells anyone with an account (other than Facebook Connect logins) to assume their username and password were in the leaked data. It says the passwords were encrypted, meaning not stored in plain text, but still vulnerable, and that a group calling itself Gnosis has claimed credit.
The line in that FAQ I’d like every PHP developer to read is near the bottom: Gawker says it is updating the affected accounts to use bcrypt. If a company has to learn that on the front page, the rest of us can learn it from their FAQ. And if you’re on PHP 5.3, you don’t need a library to do it.
Why bcrypt, briefly
Fast hashes like MD5 and SHA-1 are designed to be quick, which is precisely what you don’t want when an attacker has your whole user table and a GPU. bcrypt is built on Blowfish and has a cost factor you pick. Each step up doubles the work, so you can make every guess expensive and turn it up again as hardware gets faster. It also needs a salt per password, so two users with the same password get different hashes.
PHP 5.3 always has it
Before 5.3, PHP worked out which algorithms crypt() could use at install time, based on the operating system’s own crypt, so whether you had Blowfish depended on the box. The PHP 5.3.0 changelog fixed that: PHP now carries its own implementations of the crypt algorithms, including Blowfish based on Solar Designer’s code, and uses them when the system lacks one (Windows builds always use PHP’s). So on any 5.3 install, bcrypt through crypt() is available. I’d also make sure you’re on 5.3.2 or later, because that release changed Blowfish with an invalid cost to return a failure string instead of quietly falling back to DES.
The code
The crypt() manual spells out the salt format: $2a$, a two digit cost from 04 to 31 (the base 2 logarithm of the iteration count), another $, and 22 characters from the alphabet ./0-9A-Za-z.
function hash_password($password)
{
$salt = substr(strtr(base64_encode(openssl_random_pseudo_bytes(16)), '+', '.'), 0, 22);
$hash = crypt($password, '$2a$10$' . $salt);
if (strlen($hash) != 60) {
throw new Exception('bcrypt failed');
}
return $hash;
}
function check_password($password, $stored)
{
return crypt($password, $stored) === $stored;
}
A few things are going on there. The random bytes come from openssl_random_pseudo_bytes(), new in 5.3.0, so the salt comes from OpenSSL’s random generator instead of something guessable like mt_rand() or uniqid(). Base64 almost matches bcrypt’s alphabet, except that it uses + where bcrypt wants ., so strtr() fixes that and substr() keeps the 22 characters we need. If you don’t have the OpenSSL extension, mcrypt_create_iv(16, MCRYPT_DEV_URANDOM) works too, and it has worked on Windows since 5.3.0.
The length check matters. A good bcrypt result is 60 characters. When something is wrong with the salt, crypt() gives back something short, and you really don’t want to store that.
Checking a password needs no separate salt column. The stored hash already starts with the algorithm, cost and salt, so you pass the whole thing back into crypt() as the salt and compare the result to what you stored.
Picking a cost
10 means 2 to the 10th iterations. There’s no universal right number. My advice is to time hash_password() on your production hardware and pick the highest cost that keeps a login comfortably fast for users. Because the cost is stored inside each hash, you can raise it later and old hashes still verify.
Moving off MD5
You don’t need every user to reset. On the next successful login, when you have the plain password in hand and it matches the old hash, compute a bcrypt hash and replace the old one. Accounts that never come back stay on the weak hash, so after a while it’s worth forcing a reset for whoever is left. Gawker’s week has been a rough one, but at least the fix is short.