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.

Diagram showing how previous implementation gave mixure of hashes stored in PasswordHash column

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.

Diagram showing how new implementation stores a hash of a hash in the PasswordHash column

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.

Using format byte to identify hashing algorithm

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!

Using format byte to identify nested has-of-hash algorithm

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);

        // read the format marker from the hashed password
        if (decodedHashedPassword.Length == 0)
        {
            return PasswordVerificationResult.Failed;
        }

        // ASP.NET Core uses 0x00 and 0x01 for v2 and v3
        if (decodedHashedPassword[0] == 0xF0)
        {
            // replace the 0xF0 prefix in the stored password with 0x01 (ASP.NET Core Identity V3) and convert back to Base64
            decodedHashedPassword[0] = 0x01;
            var storedPassword = Convert.ToBase64String(decodedHashedPassword);

            // md5 hash the provided password
            var md5ProvidedPassword = GetM5Hash(providedPassword);

            // call the base implementation with the new values
            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>();
    // ...

    // Replace the existing scoped IPasswordHasher<> implementation
    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)
    {
        // Performs v3 PBKDF2 hash of provided MD5 hash
        var reHashedPassword = userManager.PasswordHasher.HashPassword(user, md5Password);

        // Replace the format marker so we know to MD5 hash 
        // provided passwords during password verification
        var passwordToStore = ReplaceFormatMarker(reHashedPassword, 0xF0);

        // Replace the old hash with the "updated marker" hash
        user.PasswordHash = passwordToStore;

        // Roll the security stamp for the user (invalidates security-related tokens)
        await userManager.UpdateSecurityStampAsync(user);

        // Save the changes to the DB and return the result
        return await userManager.UpdateAsync(user);
    }

    // Replace the fomat marker in Base64 encoded string 
    // Not the most efficient but does the job
    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

In this post I showed how you could extend the default ASP.NET Core Identity PasswordHasher<> implementation to allow migrating from insecure hash formats. This lets you verify hashes created using a legacy format (MD5 in this example), and update them to use the default Identity password hashing algorithm so the vulnerable hashes are protected in the event of a data breach.