ASP.NET Core 3.1 - Password Hasher


Ken Haggerty
Created 01/27/2020 - Updated 02/02/2020 16:22

This article will demonstrate the implementation of a password hasher. I will assume you have created a new ASP.NET Core 3.1 Razor Pages project and have implemented the first 2 articles of the series. See Tutorial: Get started with Razor Pages in ASP.NET Core .

This article is part of a series about users without Identity.


Access to the research project source code may be purchased on KenHaggerty.Com at Manage > Assets.

A project which implements users without Identity has been published to demo.kenhaggerty.com.

I enjoy writing these articles. It often enhances and clarifies my coding. The research project is a result of a lot of refactoring and hopefully provides logical segues for the articles. Thank you for supporting my efforts.

The first versions of the project at demo.kenhaggerty.com implemented a password hasher which stored 2 fields, the hash and the salt. See How to validate salted and hashed password in c# . Research for this article found Exploring the ASP.NET Core Identity PasswordHasher and Safely migrating passwords in ASP.NET Core Identity with a custom PasswordHasher which lead me to the source code for Identity at GitHub - AspNet/ Identity/ src/ Core/ PasswordHasher.cs .

The Microsoft. AspNetCore. Identity. PasswordHasher is installed with the target framework netcoreapp3.1. This PasswordHasher defaults to IdentityV3. IdentityV3 is encrypted PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. IdentityV3 is formated: { 0x01, prf(UInt32), iter count(UInt32), salt length(UInt32), salt, subkey }.

using Microsoft. AspNetCore. Identity;
var passwordHasher = new PasswordHasher<AppUser>();
var user = new AppUser();
var hashedPassword = passwordHasher.HashPassword(user, password);

The VerifyHashedPassword function returns a PasswordVerificationResult which can equal Success, SuccessRehashNeeded or Failed. SuccessRehashNeeded can notify when the password has been successfully verified using a IdentityV2 hash.

using Microsoft. AspNetCore. Identity;
bool verified = false;
var result = passwordHasher.VerifyHashedPassword(user, hashedPassword, confirmPassword);
if (result == PasswordVerificationResult.Success) verified = true;
else if (result == PasswordVerificationResult.SuccessRehashNeeded) verified = true;
else if (result == PasswordVerificationResult.Failed) verified = false;

The PasswordHasher has 2 configurable options. CompatibilityMode for IdentityV2 or IdentityV3 and the iteration count. The IterationCount option applies to IdentityV3 only.

using Microsoft. AspNetCore. Identity;
var options = new PasswordHasherOptions();
options.CompatibilityMode = PasswordHasherCompatibilityMode.IdentityV3;
options.IterationCount = 10000;

The PasswordHasher employs a 1 byte format marker. The IdentityV2 marker = 0x00(byte) and the IdentityV3 marker = 0x01(byte). The marker defines the configuration and format. IdentityV2 and IdentityV3 implement PBKDF2. In cryptography PBKDF2 (Password-Based Key Derivation Function 2) are key derivation functions with a sliding computational cost, used to reduce vulnerabilities to brute force attacks. See Wikipedia - PBKDF2 . IdentityV2 uses HMAC-SHA1 and 1000 iterations. IdentityV3 uses HMAC-SHA256 and 10000 iterations. Both use a 128-bit salt and a 256-bit subkey. The output for IdentityV3 includes configuration information in the header, IdentityV2 does not. If I wanted to use a configuration which is different from either IdentityV2 or IdentityV3, I would employ a format marker with a new unique value.

I took parts from the Microsoft. AspNetCore. Identity. PasswordHasher and created a custom password hasher which is compatible with IdentityV2 and IdentityV3 but allows you to create a new format marker and configuration.

using Microsoft. AspNetCore. Cryptography. KeyDerivation;
using System;
using System. Runtime. CompilerServices;
using System. Security. Cryptography;
public class CustomPasswordHasher
{
    // Format Markers:
    // IdentityV2: PBKDF2 with HMAC-SHA1, 128-bit salt, 256-bit subkey, 1000 iterations.
    // IdentityV2 Format: { 0x00(byte), salt, subkey }
    // IdentityV3: PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations.
    // IdentityV3 Format: { 0x01(byte), prf(UInt32), iter count(UInt32), salt length(UInt32), salt, subkey }
    // Custom = PBKDF2 with custom configuration.
    // Format: { 0xC0(byte), salt, subkey } OR
    // { 0xC0(byte), prf(UInt32), iter count(UInt32), salt length(UInt32), salt, subkey }
    // Header Info: _includeHeaderInfo
    // IdentityV3 includes configuration in the header, IdentityV2 does not.
    // Use AspNetCore: _useAspNetCore
    // Microsoft.AspNetCore.Cryptography.KeyDerivation is required
    // else use System.Security.Cryptography

    private readonly bool _useAspNetCore;
    private readonly byte _formatMarker;
    private readonly KeyDerivationPrf _prf; // Requires Microsoft.AspNetCore
    private readonly HashAlgorithmName _hashAlgorithmName;
    private readonly bool _includeHeaderInfo;
    private readonly int _saltLength;
    private readonly int _requestedLength;
    private readonly int _iterCount;

    public CustomPasswordHasher()
    {
        _useAspNetCore = true;

        // IdentityV2
        //_formatMarker = 0x00;
        //_prf = KeyDerivationPrf.HMACSHA1; // Requires Microsoft.AspNetCore
        //_hashAlgorithmName = HashAlgorithmName.SHA1;
        //_includeHeaderInfo = false;
        //_saltLength = 128 / 8; // bits/1 byte = 16
        //_requestedLength = 256 / 8; // bits/1 byte = 32        
        //_iterCount = 1000;

        // IdentityV3
        _formatMarker = 0x01;
        _prf = KeyDerivationPrf.HMACSHA256; // Requires Microsoft.AspNetCore
        _hashAlgorithmName = HashAlgorithmName.SHA256;
        _includeHeaderInfo = true;
        _saltLength = 128 / 8; // bits/1 byte = 16
        _requestedLength = 256 / 8; // bits/1 byte = 32        
        _iterCount = 10000;

        // Custom Max
        //_formatMarker = 0xC0;
        //_prf = KeyDerivationPrf.HMACSHA512; // Requires Microsoft.AspNetCore
        //_hashAlgorithmName = HashAlgorithmName.SHA512;
        //_includeHeaderInfo = true;
        //_saltLength = 512 / 8; // bits/1 byte = 64
        //_requestedLength = 512 / 8; // bits/1 byte = 64        
        //_iterCount = 100000;
    }

    public string HashPassword(string password)
    {
        if (string.IsNullOrEmpty(password)) throw new ArgumentNullException(nameof(password));

        byte[] salt = new byte[_saltLength];
        using (var rng = RandomNumberGenerator.Create())
        {
            rng.GetBytes(salt);
        }

        byte[] subkey = new byte[_requestedLength];
        if (_useAspNetCore)
        {
            subkey = KeyDerivation.Pbkdf2(password, salt, _prf, _iterCount, _requestedLength);
        }
        else
        {
            using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, _iterCount, _hashAlgorithmName);
            subkey = pbkdf2.GetBytes(_requestedLength);
        }

        var headerByteLength = 1; // Format marker only
        if (_includeHeaderInfo) headerByteLength = 13;

        var outputBytes = new byte[headerByteLength + salt.Length + subkey.Length];

        outputBytes[0] = (byte)_formatMarker;

        if (_includeHeaderInfo)
        {
            if (_useAspNetCore)
            {
                WriteNetworkByteOrder(outputBytes, 1, (uint)_prf);
            }
            else
            {
                var shaInt = 1;
                if (_hashAlgorithmName == HashAlgorithmName.SHA1) shaInt = 0;
                else if (_hashAlgorithmName == HashAlgorithmName.SHA256) shaInt = 1;
                else if (_hashAlgorithmName == HashAlgorithmName.SHA512) shaInt = 2;

                WriteNetworkByteOrder(outputBytes, 1, (uint)shaInt);
            }

            WriteNetworkByteOrder(outputBytes, 5, (uint)_iterCount);
            WriteNetworkByteOrder(outputBytes, 9, (uint)_saltLength);
        }

        Buffer.BlockCopy(salt, 0, outputBytes, headerByteLength, salt.Length);
        Buffer.BlockCopy(subkey, 0, outputBytes, headerByteLength + _saltLength, subkey.Length);

        return Convert.ToBase64String(outputBytes);
    }

    public bool VerifyPassword(string hashedPassword, string enteredPassword)
    {
        if (string.IsNullOrEmpty(enteredPassword) || string.IsNullOrEmpty(hashedPassword)) return false;

        byte[] decodedHashedPassword;
        try
        {
            decodedHashedPassword = Convert.FromBase64String(hashedPassword);
        }
        catch (Exception)
        {
            return false;
        }

        if (decodedHashedPassword.Length == 0) return false;

        // Read the format marker          
        var verifyMarker = (byte)decodedHashedPassword[0];
        if (_formatMarker != verifyMarker) return false;

        try
        {
            if (_includeHeaderInfo)
            {
                // Read header information
                var shaUInt = (uint)ReadNetworkByteOrder(decodedHashedPassword, 1);
                var verifyPrf = shaUInt switch
                {
                    0 => KeyDerivationPrf.HMACSHA1,
                    1 => KeyDerivationPrf.HMACSHA256,
                    2 => KeyDerivationPrf.HMACSHA512,
                    _ => KeyDerivationPrf.HMACSHA256,
                };
                if (_prf != verifyPrf) return false;

                var verifyAlgorithmName = shaUInt switch
                {
                    0 => HashAlgorithmName.SHA1,
                    1 => HashAlgorithmName.SHA256,
                    2 => HashAlgorithmName.SHA512,
                    _ => HashAlgorithmName.SHA256,
                };
                if (_hashAlgorithmName != verifyAlgorithmName) return false;

                int iterCountRead = (int)ReadNetworkByteOrder(decodedHashedPassword, 5);
                if (_iterCount != iterCountRead) return false;

                int saltLengthRead = (int)ReadNetworkByteOrder(decodedHashedPassword, 9);
                if (_saltLength != saltLengthRead) return false;
            }

            var headerByteLength = 1; // Format marker only
            if (_includeHeaderInfo) headerByteLength = 13;

            // Read the salt
            byte[] salt = new byte[_saltLength];
            Buffer.BlockCopy(decodedHashedPassword, headerByteLength, salt, 0, salt.Length);

            // Read the subkey (the rest of the payload)
            int subkeyLength = decodedHashedPassword.Length - headerByteLength - salt.Length;
                
            if (_requestedLength != subkeyLength) return false;
                
            byte[] expectedSubkey = new byte[subkeyLength];
            Buffer.BlockCopy(decodedHashedPassword, headerByteLength + salt.Length, expectedSubkey, 0, expectedSubkey.Length);
                
            // Hash the incoming password and verify it
            byte[] actualSubkey = new byte[_requestedLength];
            if (_useAspNetCore)
            {
                actualSubkey = KeyDerivation.Pbkdf2(enteredPassword, salt, _prf, _iterCount, subkeyLength);
            }
            else
            {
                using var pbkdf2 = new Rfc2898DeriveBytes(enteredPassword, salt, _iterCount, _hashAlgorithmName);
                actualSubkey = pbkdf2.GetBytes(_requestedLength);
            }

            return ByteArraysEqual(actualSubkey, expectedSubkey);
        }
        catch
        {
            // This should never occur except in the case of a malformed payload, where
            // we might go off the end of the array. Regardless, a malformed payload
            // implies verification failed.
            return false;
        }
    }

    // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized.
    [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
    private static bool ByteArraysEqual(byte[] a, byte[] b)
    {
        if (a == null && b == null) return true;
        if (a == null || b == null || a.Length != b.Length) return false;
        var areSame = true;
        for (var i = 0; i < a.Length; i++) { areSame &= (a[i] == b[i]); }
        return areSame;
    }

    private static uint ReadNetworkByteOrder(byte[] buffer, int offset)
    {
        return ((uint)(buffer[offset + 0]) << 24)
            | ((uint)(buffer[offset + 1]) << 16)
            | ((uint)(buffer[offset + 2]) << 8)
            | ((uint)(buffer[offset + 3]));
    }

    private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value)
    {
        buffer[offset + 0] = (byte)(value >> 24);
        buffer[offset + 1] = (byte)(value >> 16);
        buffer[offset + 2] = (byte)(value >> 8);
        buffer[offset + 3] = (byte)(value >> 0);
    }
}

The VerifyPassword function returns a bool value (true = verified, false = failed). By verifying the format marker, you can implement a rehash for 2 known markers. A rehash would verify the password with the old configuration then create the new hashed password with the new configuration.

The string length of the hashed password depends on the configuration. IdentityV2 outputs a base 64 string length of 68 and IdentityV3 outputs a length of 84. The research project has a page for the custom password hasher which displays the output string length and allows you to test and analyze different configurations.

Password Hasher

If you have already created an AppUser class, modify the AppUser model to store the password hash with an appropriate MaxLength attribute.

Edit Entities > AppUser.cs:
public class AppUser
{
    [Key]
    public int Id { get; set; }

    [Required]
    [Display(Name = "Login Name")]
    [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
    public string LoginName { get; set; }

    [Required]
    [StringLength(32)]
    public string LoginNameUppercase { get; set; }

    [Required]
    [MaxLength(84, ErrorMessage = "The {0} is max {1} characters long.")]
    public string PasswordHash { get; set; }
}

Add a PasswordHash migration.

Package Manager Console:
add-migration PasswordHash -o Data/Migrations
DotNet CLI:
dotnet-ef migrations add PasswordHash -o Data/Migrations

The add migration produces a MigrationBuilder which will drop the Password column and create a new PasswordHash column with nullable = false and maxLength = 84. If there is an existing Administrator user you will get a warning: An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy. The file name prepends a date time stamp to PasswordHash.

20200108023734_PasswordHash.cs:
public partial class PasswordHash : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "Password",
            table: "AppUsers");

        migrationBuilder.AddColumn<string>(
            name: "PasswordHash",
            table: "AppUsers",
            maxLength: 84,
            nullable: false,
            defaultValue: "");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "PasswordHash",
            table: "AppUsers");

        migrationBuilder.AddColumn<string>(
            name: "Password",
            table: "AppUsers",
            type: "nvarchar(32)",
            maxLength: 32,
            nullable: false,
            defaultValue: "");
    }
}

If all looks good, update the database.

Package Manager Console:
update-database
DotNet CLI:
dotnet-ef database update

An existing Administrator user will have an empty string for the PasswordHash which makes it useless. We need to update EnsureAdministrator.cs to create an administrator with a hashed password. You can add code to reset an Administrator after migration.

Edit Services > EnsureAdministrator.cs:
public class EnsureAdministrator : IHostedService
{
    // We need to inject the IServiceProvider so we can create the scoped service, ApplicationDbContext
    private readonly IServiceProvider _serviceProvider;
    public EnsureAdministrator(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // Create a new scope to retrieve scoped services
        using var scope = _serviceProvider.CreateScope();
        // Get the DbContext instance
        var applicationDbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        string adminLoginName = "Administrator";
        int adminId = await applicationDbContext.AppUsers
            .AsNoTracking()
            .Where(a => a.LoginNameUppercase == adminLoginName.ToUpper())
            .Select(a => a.Id)
            .FirstOrDefaultAsync()
            .ConfigureAwait(false);

        // Reset Administrator after migration
        var resetAdministrator = false;
        if (adminId != 0 && resetAdministrator)
        {
            var admin = await applicationDbContext.AppUsers
            .Where(a => a.Id == adminId)
            .FirstOrDefaultAsync()
            .ConfigureAwait(false);

            applicationDbContext.AppUsers.Remove(admin);
            try
            {
                applicationDbContext.SaveChanges();
            }
            catch (DbUpdateConcurrencyException)
            {
                throw new InvalidOperationException("DbUpdateConcurrencyException occurred deleting old " +
                    "admin user in EnsureAdminHostedService.cs.");
            }
            catch (DbUpdateException)
            {
                 throw new InvalidOperationException("DbUpdateException occurred deleting old admin user in " +
                    "EnsureAdminHostedService.cs.");
            }

            adminId = 0;
        }

        // Add Administrator if not found
        if (adminId == 0)
        {
            var password = "P@ssw0rd";

            var customPasswordHasher = new CustomPasswordHasher();
            var passwordHash = customPasswordHasher.HashPassword(password);

            var adminUser = new AppUser()
            {
                LoginName = adminLoginName,
                LoginNameUppercase = adminLoginName.ToUpper(),
                PasswordHash = passwordHash
            };

            applicationDbContext.AppUsers.Add(adminUser);
            try
            {
                applicationDbContext.SaveChanges();
            }
            catch (DbUpdateConcurrencyException)
            {
                throw new InvalidOperationException($"DbUpdateConcurrencyException occurred
                    creating new admin user in EnsureAdminHostedService.cs.");
            }
            catch (DbUpdateException)
            {
                throw new InvalidOperationException($"DbUpdateException occurred creating new
                    admin user in EnsureAdminHostedService.cs.");
            }
        }
    }

    // noop
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Update the AuthenticateUser function in Login.cshtml.cs to verify the hashed password.

Edit Login.cshtml.cs > AuthenticateUser:
private async Task<AppUser> AuthenticateUser(string login, string password)
{
    if (string.IsNullOrEmpty(login) || string.IsNullOrEmpty(password))
        return null;

    var user = await _context.AppUsers
        .AsNoTracking()
        .Where(a => a.LoginNameUppercase == login.ToUpper())
        .FirstOrDefaultAsync()
        .ConfigureAwait(false);

    if (user == null)
        return null;

    //if (password != user.Password)
    //    return null;

    //return user;

    var customPasswordHasher = new CustomPasswordHasher();
    if (customPasswordHasher.VerifyPassword(user.PasswordHash, password))
    {
        return user;
    }
    else
    {
        // Login Failed
        return null;
    }
}

Build, run and test. Login with Login Name = Administrator and Password = P@ssw0rd.


Article Tags:

Authorization EF Core Model

1 Following


Comment Count = 0

Please log in to comment or follow.

Login Register