Migrating Password Hashing Algorithms In Lucee CFML 5.3.7.47
Over the weekend, I looked at using the Password4j password hashing library in Lucee CFML. One of the APIs that the Password4j library includes is the ability to update a hash with new hashing characteristics. This is actually something I've had to do in the past - migrating a system to a stronger, more security hashing algorithm. As such, I thought it would be fun to demonstrates using the Password4j library in Lucee CFML 5.3.7.47.
Updating a password hash is a fairly straightforward process. But, it has one major caveat: it can only be done at login time. The reason for this is that password hashes are one-way hashes. Meaning, once we store the user's password hash in our database, we no longer know what their actual password is. That's the whole point of using a secure password hash - to protect the user's password even in the event that our database is compromised by a malicious actor.
So, in order to update a password hash, we need to have the user's raw password on hand. And, the only time we ever have that value (after sign-up) is during the authentication workflow. Migrating a password hash then becomes a matter of following these simple steps during login (let's assume for the discussion that we're migrating from an insecure MD5 hash to a secure BCrypt hash):
Try to authenticate using the secure BCrypt hash.
If BCrypt hash works, user is logged-in (success!).
If BCrypt hash fails, check to see if MD5 hash can be verified.
If MD5 hash can be verified, update persisted hash to use BCrypt.
If neither BCrypt nor MD5 hash could be verified, authenticated failed.
Since password hash migration only ever has to happen once per user (per algorithm change), we want to start the authentication process with the assumption that all users are using the correct hashing algorithm. Then, only as needed, fallback to verifying authentication against the older hashing algorithm. In essence, we want to optimize for the happy path since this will be the primary path in the vast majority of authentication events.
To see this in action, I've stubbed-out the makings of an authentication workflow using my Password.cfc
ColdFusion component wrapper for Password4j. In this workflow, we have to migrate the password hash from MD5 to BCrypt:
<cfscript>
passwordUtil = new Password([
"./lib/password4j/commons-lang3-3.12.0.jar",
"./lib/password4j/password4j-1.5.3.jar",
"./lib/password4j/slf4j-api-1.7.30.jar",
"./lib/password4j/slf4j-nop-1.7.30.jar"
]);
// For the sake of simplicity, assume this is a FORM POST over HTTPS.
username = "ben@bennadel.com";
password = "intelligentUrgency";
authenticateUser( username, password );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
/**
* I attempt to authenticate the given credentials for the application login.
*/
public boolean function authenticateUser(
required string username,
required string password
) {
var expectedHash = getPasswordHashForUser( username );
// Migrating password hashing algorithms only needs to happen once per user. As
// such, we should optimize the login workflow to assume the user has the correct
// hash; and then, FALL BACK to the old algorithm only as needed. In this case,
// we want to get all users on the BCrypt algorithm so we're going to check for
// the BCrypt outcome first.
// --
// NOTE: Since our PasswordUtil is expecting to verify against a BCrypt hash, we
// need to wrap this in a Try/Catch as it will throw an error when attempting to
// consume an MD5 hash as if it were a BCrypt hash.
try {
if ( passwordUtil.bcryptHashVerify( password, expectedHash ) ) {
return( true );
}
} catch ( any error ) {
systemOutput( "Could not verifying BCrypt hash, moving onto older hash.", true );
}
// Now that we've failed to verify the user's password against the BCrypt hash,
// let's check to see if the password can be verified against the OLD, INSECURE
// MD5 hash.
if ( expectedHash == hash( password ) ) {
systemOutput( "Older, insecure MD5 hash [#expectedHash#] verified for user.", true );
// TIME TO MIGRATE THE HASHING ALGORITHM: This user's record is still using
// the OLD, INSECURE MD5 hash. We need to update their record to use the
// modern, BCrypt hash.
setPasswordHashForUser( username, passwordUtil.bcryptHashGet( password ) );
return( true );
}
// If we made it this far, none of the password hashing algorithms could be
// verified - the user provided the wrong password.
return( false );
}
/**
* I get the persisted password hash for the given user.
*/
public string function getPasswordHashForUser( required string username ) {
// Return the MD5-hash of the "intelligentUrgency".
return( "d49a5b5dff1f7dfcc2fd3d0b85dcd0a3" );
}
/**
* I persist the given password hash for the given user.
*/
public void function setPasswordHashForUser(
required string username,
required string passwordHash
) {
systemOutput( "Storing hash [#passwordHash#] for user [#username#]", true );
}
</cfscript>
As you can see, the first thing we try to do in the authenticateUser()
method is to verify the user's credentials against the BCrypt hash. This is the happy path; and, if it works, the user is logged-in. However, if it fails, we fallback to checking against the MD5 hash. Which, if successful, then precipitates a migration to the new BCrypt hash.
If we now run this ColdFusion code, we can see the following server logs:
[INFO] Could not verifying BCrypt hash, moving onto older hash.
[INFO] Older, insecure MD5 hash [d49a5b5dff1f7dfcc2fd3d0b85dcd0a3] verified for user.
[INFO] Storing hash [$2b$10$VxeTfuPjf3JjfTnw5GK75uhtS5jlP2m8O/.TFwZKpMseRy55dKnJ6] for user [ben@bennadel.com]
The user's password hash has been successfully migrated from an insecure MD5 hash to a secure BCrypt hash.
As you can see, migrating a password hash really isn't that complicated. And, it can be multi-step. Imagine that we wanted to perform a subsequent migration from BCrypt to Argon2id. In that case, all we would have to do is make Argon2 the "happy path" and then fallback to checking BCrypt and MD5 hashes.
Epilogue on OWASP Recommendations For Upgrading Legacy Password Hashes
In the OWASP Password Storage Cheat Sheet, there is a section specifically for migrating password hashes. In it, they mention that a true migration can only be done at authentication time (as I've outlined above). However, they point out that since this requires action on behalf of the user, there's a good chance that it may never happen. As such, OWASP further recommends one of two actions:
Expire (and expunge) old password hashes if the user hasn't logged into the application for a long time.
Wrap the older password hash inside the newer password hash (known as "layering" the hashes).
Here's a partial reproduction of the suggestions from OWASP (Open Web Application Security Project):
For older applications built using less secure hashing algorithms such as MD5 or SHA-1, these hashes should be upgraded to modern password hashing algorithms as described above. When the user next enters their password (usually by authenticating on the application), it should be re-hashed using the new algorithm. It would also be good practice to expire the users' current password and require them to enter a new one so that any older (less secure) hashes of their password are no longer useful to an attacker.
However, this approach means that old (less secure) password hashes will be stored in the database until the user logs in. Two main approaches can be taken to avoid this dilemma.
One method is to expire and delete the password hashes of users who have been inactive for an extended period and require them to reset their passwords to login again. Although secure, this approach is not particularly user-friendly. Expiring the passwords of many users may cause issues for support staff or may be interpreted by users as an indication of a breach.
An alternative approach is to use the existing password hashes as inputs for a more secure algorithm. For example, if the application originally stored passwords as
md5($password)
, this could be easily upgraded tobcrypt(md5($password))
. Layering the hashes avoids the need to know the original password; however, it can make the hashes easier to crack. These hashes should be replaced with direct hashes of the users' passwords next time the user logs in.
Stay secure, my friends!
Want to use code from this post? Check out the license.
Reader Comments