Some time ago I wrote a post on the default ASP.NET Core Identity PasswordHasher<> implementation, and how it enables backwards compatibility between password hashing algorithms. In a follow up post, I showed how to create a custom IPasswordHasher<> to slowly migrate existing BCrypt password hashes to the default ASP.NET Core Identity hashing format.

Unfortunately, the implementation in that post is no good for migrating weak password hashes to something more secure. If you are migrating from a weak hashing strategy, you'll end up with some of your passwords continually stored using the weak strategy. Passwords are only stored securely for users that had logged in recently. In this post I'll show a better implementation that solves that problem by taking a hash of a hash.

Disclaimer: You should always think carefully before replacing security-related components, as a lot of effort goes into making the default components secure by default. This article solves a specific problem, but you should only use it if you need it!

The code I'm going to show is based on the ASP.NET Core 2.2 release, but it should work with any 2.x version of ASP.NET Core Identity.

Background

As I discussed in the previous post, the IPasswordHasher<> interface has two responsibilities:

Hash a password so it can be stored in a database

Verify a provided plain-text password matches a previously stored hash

In this post I'm focusing on the scenario where you want to add ASP.NET Core Identity to an existing app, and you already have a database that contains usernames and password hashes.

The problem is that your password hashes are stored using a hash format that isn't compatible with ASP.NET Core Identity and is generally insecure (e.g. SHA1 or MD5). In this example, I'm going to assume your passwords are hashed using MD5, but you could easily apply it to other hashing algorithms. The Md5PasswordHasher<> we will create allows you to verify existing password MD5 hashes, while allowing you to create new hashes using the default ASP.NET Core Identity hashing algorithm.

In my previous implementation, I achieved this by creating a custom IPasswordHasher<> implementation that used existing password hashes (BCrypt in that case) to verify the password, and then re-hashed the password on successful login. The downside with that approach is that the IdentityUser.PasswordHash column ends up with a mixture of different hashes stored in it: ASP.NET Core Identity v3 (PBKDF2) hashes for users that have logged in recently, and BCrypt for those that haven't.

From a technical point of view, this isn't a big issue - the format marker byte allows the IPasswordHasher<> implementation to interpret and handle the different hash formats.

However this approach is problematic if your "old" hash format is weak or obsolete, e.g. MD5. If that's the case, you'll have a mix of secure (ASP.NET Core Identity PBKDF2) hashes and thoroughly insecure MD5 hashes stored in your database. If your app ends up having a data breach and appearing on HaveIBeenPwned, those MD5 hashes will be cracked very quickly. 😟

So how can you handle this if your current passwords are stored as MD5 hashes? You can't just "convert" them to a secure format, as that requires knowing the plaintext password for every user.

The correct approach is to apply the new hash algorithm to the MD5 hashes themselves, not the plaintext password. This gives you a "hash-inside-a-hash", so in the event of a data breach your users' passwords have much better protection. As users sign in, you can slowly re-hash their passwords to the un-nested form, but in the mean time you're not exposing insecure MD5 hashes.

When a user logs in and verifies their password, you can re-hash the password using the ASP.NET Core Identity default hash function. That way, hashes will slowly migrate from the legacy hash-inside-a-hash format to the default hash format.

Using a format byte to distinguish hash implementations

As discussed in a previous post, the default PasswordHasher<> implementation already handles multiple hashing formats, namely two different versions of PBKDF2. It does this by storing a single-byte "format-marker" along with the password hash. The whole combination is then Base64 encoded and stored in the database as a string.

When a password needs to be verified and compared to a stored hash, the hash is read from the database, decoded from Base64 to bytes, and the first byte is inspected. If the byte is 0x00 , the password hash was created using v2 of the hashing algorithm. If the byte is 0x01 , then v3 was used.

We can maintain compatibility with the base PasswordHasher algorithm by storing our own custom format marker in the first byte of the password hash in a similar way. 0x00 and 0x01 are already taken, so I chose 0xF0 for this case as it seems like it should be safe for a while!

A slightly abbreviated version of the Md5PasswordHasher implementation is shown below (you can find the complete source code on GitHub). When a password hash and plain-text password are provided for verification, we follow a similar approach to the default PasswordHasher<> . We convert the password from Base64 into bytes, and examine the first byte. If the hash starts with 0xF0 then we have a hash-inside-a-hash. If it starts with something else, then we pass the original stored hashed and provided plain-text password to the base PasswordHasher<> implementation.

If we find we are working with a hash-inside-a-hash, then we replace the 0xF0 format-marker with 0x01 , and convert it back to a Base64 string for use with the base PasswordHasher<> implementation. We also take the provided password and create an MD5 hash of it. We then pass the MD5 hash as the "provided password" to the base VerifyHashedPassword method. This then hashes it using the Identity v3 PBKDF2 format (thanks to the 0x01 format marker we added) and compares the result with storedPassword .

public class Md5PasswordHasher < TUser > : PasswordHasher < TUser > where TUser : class { public override PasswordVerificationResult VerifyHashedPassword ( TUser user , string hashedPassword , string providedPassword ) { byte [ ] decodedHashedPassword = Convert . FromBase64String ( hashedPassword ) ; if ( decodedHashedPassword . Length == 0 ) { return PasswordVerificationResult . Failed ; } if ( decodedHashedPassword [ 0 ] == 0xF0 ) { decodedHashedPassword [ 0 ] = 0x01 ; var storedPassword = Convert . ToBase64String ( decodedHashedPassword ) ; var md5ProvidedPassword = GetM5Hash ( providedPassword ) ; var result = base . VerifyHashedPassword ( user , storedPassword , md5ProvidedPassword ) ; return result == PasswordVerificationResult . Success ? PasswordVerificationResult . SuccessRehashNeeded : result ; } return base . VerifyHashedPassword ( user , hashedPassword , providedPassword ) ; } public static string GetM5Hash ( string input ) { using ( MD5 md5Hash = MD5 . Create ( ) ) { var bytes = md5Hash . ComputeHash ( Encoding . UTF8 . GetBytes ( input ) ) ; return Convert . ToBase64String ( bytes ) ; } } }

If the provided password was correct (the base implementation returned PasswordVerificationResult.Success ) then we force the ASP.NET Core Identity system to re-hash the password. This strips out the MD5 layer from the hash, leaving you with a "raw" ASP.NET Core Identity v3 PBKDF2 format hash stored in the database.

New passwords will always be created with the default v3 PBKDF2 format anyway, as we don't override the HashPassword method.

You can replace the default PasswordHasher<> implementation in your application by registering the Md5PasswordHasher in Startup.ConfigureServices() . There's a number of ways to do this, but I show how you can use the Replace() extension method below. Make sure to add this line after calling AddDefaultIdentity<>() or AddIdentity<>() :

public void ConfigureServices ( IServiceCollection services ) { services . AddDefaultIdentity < IdentityUser > ( ) . AddDefaultUI ( UIFramework . Bootstrap4 ) . AddEntityFrameworkStores < ApplicationDbContext > ( ) ; services . Replace ( new ServiceDescriptor ( serviceType : typeof ( IPasswordHasher < IdentityUser > ) , implementationType : typeof ( Md5PasswordHasher < IdentityUser > ) , ServiceLifetime . Scoped ) ) ; }

This is all you need in the normal operation of your application, but before you can run your app you need to create your hash-inside-a-hash values.

Converting stored MD5 passwords to support the Md5PasswordHasher

The approach of extending the default PasswordHasher<> implementation shown in this post requires you to have already stored your passwords against each IdentityUser using the hash-inside-a-hash mechanism and the 0xF0 format-marker. That means you'll need to re-hash your existing MD5 hashes with the ASP.NET Core Identity password hasher.

How you do this is highly dependent on how and where your passwords are stored. I've provided a basic extension method below that takes an IdentityUser and an existing MD5 hash string and produces a string in a format compatible with the Md5PasswordHasher .

public static class UserManagerExtensions { public static async Task < IdentityResult > SetMd5PasswordForUser ( this UserManager < IdentityUser > userManager , IdentityUser user , string md5Password ) { var reHashedPassword = userManager . PasswordHasher . HashPassword ( user , md5Password ) ; var passwordToStore = ReplaceFormatMarker ( reHashedPassword , 0xF0 ) ; user . PasswordHash = passwordToStore ; await userManager . UpdateSecurityStampAsync ( user ) ; return await userManager . UpdateAsync ( user ) ; } private static string ReplaceFormatMarker ( string passwordHash , byte formatMarker ) { var bytes = Convert . FromBase64String ( passwordHash ) ; bytes [ 0 ] = formatMarker ; return Convert . ToBase64String ( bytes ) ; } }

During your migration to ASP.NET Core Identity you would create a new IdentityUser for each of your existing users, and then call SetMd5PasswordForUser , passing in the md5 formatted password.

_userManager . SetMd5PasswordForUser ( user , md5Password ) ;

I have a basic proof of concept for this in the sample app on GitHub. It's a little contrived, but you can register as a new user in the sample (which stores the password hash as v3 PBKDF2). The home page then lets you enter a new password which is MD5 hashed and saved to the current IdentityUser using the SetMd5PasswordForUser extension method.

If you log out, and then sign back in with the new password, the nested hash-within-a-hash will automatically be re-hashed to strip out the MD5 layer again, leaving the "raw" v3 PBKDF2 format hash (as per the Md5PasswordHasher implementation).

Summary