ASP.NET Core 8.0 - Password Hasher

This article will describe the implementation of a custom password hasher. You should review the earlier articles of the Cookies And Claims Project series. Registered users can download the ASP.NET Core 8.0 - Cookies And Claims Project for free.

Cookies And Claims Project and Article Series

Free project download for registered users!

I developed the Cookies And Claims Project (CACP) to demonstrate a simple cookie authentication scheme and claim-based authorization with a clear and modifiable design. The CACP is developed with Visual Studio 2022 and the MS Long Term Support (LTS) version .NET 8.0 framework. All Errors, Warnings, and Messages from Code Analysis have been mitigated. The CACP implements utilities like an AES Cipher, Password Hasher, native JavaScript client form validation, password requirements UI, Bootstrap Native message modal generator, loading/spinner generator, SignalR online user count, and an automatic idle logout for users with administration permissions.

The CACP implements a group of utilities: AES Cipher, Password Hasher, Password Validation, Message Generator, and Spinner Generator. This article describes the Password Hasher. The Net 5.0 and Net 6.0 Identity V3 specification implemented SHA256 with 10000 iterations. Net 7.0 and above implements an upgraded V3 specification with SHA512 and 100000 iterations. The utility is implemented with the PasswordHasherV3 and PasswordHasherV3VerificationResult classes. The PasswordHasherV3 class implements the Identity V3 new specification password hashing and updates the hash from the old V3 specification if detected.

PasswordHasherV3.cs:
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.
namespace CookiesAndClaims.Services;
/// <summary>
/// Implements the Identity V3 new specification password hashing and updating the hash from the old specification.
/// </summary>
/// <remarks>
/// Password Hash Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey }
/// (All UInt32s are stored big-endian.)
/// PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations.
/// Net 7.0 and above implements an upgraded V3 specification with SHA512 and 100000 iterations.
/// PBKDF2 with HMAC-SHA512, 128-bit salt, 256-bit subkey, 100000 iterations.
/// https://github.com/dotnet/AspNetCore/blob/main/src/Identity/Extensions.Core/src/PasswordHasher.cs
/// https://github.com/dotnet/aspnetcore/pull/40987
/// </remarks>
public static class PasswordHasherV3
{
    /// <summary>
    /// IdentityV3 Format Marker
    /// </summary>
    private const byte _formatMarker = (byte)0x01;
    /// <summary>
    /// Pseudorandom Function
    /// https://en.wikipedia.org/wiki/Pseudorandom_function_family
    /// </summary>
    private const KeyDerivationPrf _prf = KeyDerivationPrf.HMACSHA512;
    private const KeyDerivationPrf _prfOld = KeyDerivationPrf.HMACSHA256;
    private const string _prfSHA512String = "HMACSHA512";
    private const string _prfSHA256String = "HMACSHA256";
    private const int _saltByteLength = 16;
    private const int _subkeyByteLength = 32;
    private const int _iterations = 100000;
    private const int _iterationsOld = 10000;
    private const int _headerByteLength = 13;

    /// <summary>
    /// Creates a hashed representation of the supplied <paramref name="password" />.
    /// </summary>
    /// <param name="password">The password to hash.</param>
    /// <returns>
    /// A <see cref="string" /> with a hashed representation of the supplied <paramref name="password" />.
    /// </returns>
    public static string HashPassword(string password)
    {
        if (string.IsNullOrEmpty(password)) throw new ArgumentNullException(nameof(password));

        // Create a random salt
        byte[] salt = new byte[_saltByteLength];
        using (var rng = RandomNumberGenerator.Create())
            rng.GetBytes(salt);

        byte[] subkey = new byte[_subkeyByteLength];
        subkey = KeyDerivation.Pbkdf2(password, salt, _prf, _iterations, _subkeyByteLength);

        var outputBytes = new byte[_headerByteLength + _saltByteLength + _subkeyByteLength];
        outputBytes[0] = _formatMarker;
        WriteNetworkByteOrder(outputBytes, 1, (uint)_prf);
        WriteNetworkByteOrder(outputBytes, 5, (uint)_iterations);
        WriteNetworkByteOrder(outputBytes, 9, (uint)_saltByteLength);
        Buffer.BlockCopy(salt, 0, outputBytes, _headerByteLength, _saltByteLength);
        Buffer.BlockCopy(subkey, 0, outputBytes, _headerByteLength + _saltByteLength, _subkeyByteLength);

        return Convert.ToBase64String(outputBytes);
    }

    /// <summary>
    /// Compares the <paramref name="enteredPassword" /> against the <paramref name="hashedPassword" />.
    /// </summary>
    /// <param name="hashedPassword">The hash value for a user's stored password.</param>
    /// <param name="enteredPassword">The password supplied for comparison.</param>
    /// <returns>
    /// A <see cref="PasswordHasherV3VerificationResult" /> indicating the result of a password hash comparison.
    /// </returns>
    public static PasswordHasherV3VerificationResult VerifyPassword(string hashedPassword, string enteredPassword)
    {
        if (string.IsNullOrEmpty(hashedPassword))
            return PasswordHasherV3VerificationResult.Failed("Hashed password is null or empty.");
        if (string.IsNullOrEmpty(enteredPassword))
            return PasswordHasherV3VerificationResult.Failed("Entered password is null or empty.");

        byte[] decodedHashedPassword;
        try
        {
            decodedHashedPassword = Convert.FromBase64String(hashedPassword);
        }
        catch
        {
            return PasswordHasherV3VerificationResult.Failed("Hashed password failed to decode.");
        }
        if (decodedHashedPassword.Length == 0)
            return PasswordHasherV3VerificationResult.Failed("Decoded password length = 0.");

        // Verify IdentityV3 Format Marker
        if (decodedHashedPassword[0] != _formatMarker)
            return PasswordHasherV3VerificationResult.Failed("IdentityV3 Format Marker is invalid.");

        byte[] salt = new byte[_saltByteLength];
        byte[] expectedSubkey = new byte[_subkeyByteLength];
        byte[] verifySubkey = new byte[_subkeyByteLength];

        try
        {
            // Read header information                    
            uint prfUIntRead = (uint)ReadNetworkByteOrder(decodedHashedPassword, 1);
            int iterCountRead = (int)ReadNetworkByteOrder(decodedHashedPassword, 5);
            int saltSizeRead = (int)ReadNetworkByteOrder(decodedHashedPassword, 9);

            // Verify IdentityV3 HMACSHA512
            if (prfUIntRead != (uint)_prf)
            {
                // If KeyDerivationPrf identifier = HMACSHA256, verify with the old specification.
                if (prfUIntRead == (uint)_prfOld)
                {
                    // Verify Old iteration count.
                    if (iterCountRead != _iterationsOld)
                        return PasswordHasherV3VerificationResult.Failed("Old iteration count is invalid.");

                    // Verify salt length
                    if (saltSizeRead != _saltByteLength)
                        return PasswordHasherV3VerificationResult.Failed("Old salt length is invalid.");

                    // Read the salt
                    Buffer.BlockCopy(decodedHashedPassword, _headerByteLength, salt, 0, _saltByteLength);

                    // Get the hashed subkey (the rest of the payload)
                    int subkeyLengthRead = decodedHashedPassword.Length - _headerByteLength - _saltByteLength;

                    // Verify subkey length
                    if (subkeyLengthRead != _subkeyByteLength)
                        return PasswordHasherV3VerificationResult.Failed("Old subkey length is invalid.");

                    // Read the hashed subkey
                    Buffer.BlockCopy(decodedHashedPassword, _headerByteLength + _saltByteLength, expectedSubkey, 0, _subkeyByteLength);

                    // Hash the incoming password with original salt
                    verifySubkey = KeyDerivation.Pbkdf2(enteredPassword, salt, _prfOld, _iterationsOld, _subkeyByteLength);

                    // Compare the subkeys
                    if (!ByteArraysEqual(verifySubkey, expectedSubkey))
                        return PasswordHasherV3VerificationResult.Failed("Old subkeys do not match.");

                    // Return a Succeeded result with an updated password hash
                    return PasswordHasherV3VerificationResult.Rehashed(HashPassword(enteredPassword));
                }
                return PasswordHasherV3VerificationResult.Failed("KeyDerivationPrf identifier is invalid.");
            }

            // Verify iteration count
            if (iterCountRead != _iterations)
                return PasswordHasherV3VerificationResult.Failed("Iteration count is invalid.");

            // Verify salt length
            if (saltSizeRead != _saltByteLength)
                return PasswordHasherV3VerificationResult.Failed("Salt length is invalid.");

            // Read the salt
            Buffer.BlockCopy(decodedHashedPassword, _headerByteLength, salt, 0, _saltByteLength);

            // Get the hashed subkey (the rest of the payload)
            int subkeyLength = decodedHashedPassword.Length - _headerByteLength - _saltByteLength;

            // Verify subkey length
            if (_subkeyByteLength != subkeyLength)
                return PasswordHasherV3VerificationResult.Failed("Subkey length is invalid.");

            // Read hashed the subkey
            Buffer.BlockCopy(decodedHashedPassword, _headerByteLength + _saltByteLength, expectedSubkey, 0, _subkeyByteLength);

            // Hash the incoming password with original salt
            verifySubkey = KeyDerivation.Pbkdf2(enteredPassword, salt, _prf, _iterations, _subkeyByteLength);

            // Compare subkeys for equal
            if (!ByteArraysEqual(verifySubkey, expectedSubkey))
                return PasswordHasherV3VerificationResult.Failed("Subkeys do not match.");

            // Return a Succeeded result
            return PasswordHasherV3VerificationResult.Success;
        }
        catch
        {
            // This should never occur except in the case of a malformed payload.
            return PasswordHasherV3VerificationResult.Failed("Hashed password has a malformed payload.");
        }
    }

    /// <summary>
    /// Extracts the <see cref="KeyDerivationPrf" /> for the <paramref name="hashedPassword" />.
    /// </summary>
    /// <param name="hashedPassword">The hash value for a user's stored password.</param>
    /// <returns>
    /// A <see cref="string" /> indicating the embedded prf code, if found.
    /// </returns>
    public static string GetPasswordHashPRF(string? hashedPassword)
    {
        if (string.IsNullOrEmpty(hashedPassword)) return "Hashed password is null or empty.";
        byte[] decodedHashedPassword;
        try
        {
            decodedHashedPassword = Convert.FromBase64String(hashedPassword);
        }
        catch
        {
            return "ERROR: Hashed password failed to decode.";
        }
        if (decodedHashedPassword.Length == 0)
            return "ERROR: Decoded password length = 0.";

        try
        {
            // Read header information                    
            uint prfUIntRead = (uint)ReadNetworkByteOrder(decodedHashedPassword, 1);

            // Verify IdentityV3 HMACSHA512
            if (prfUIntRead == (uint)_prf) return _prfSHA512String;

            // If KeyDerivationPrf identifier = HMACSHA256, verify with the old specification.
            if (prfUIntRead == (uint)_prfOld) return _prfSHA256String;

            return "ERROR: KeyDerivationPrf identifier is invalid.";
        }
        catch
        {
            // This should never occur except in the case of a malformed payload.
            return "ERROR: Hashed password has a malformed payload.";
        }
    }

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

Notice the GetPasswordHashPRF method. You cannot rehash the password without the original password, but you can extract the embedded Pseudorandom Function identifier.

string passwordHashPRF = PasswordHasherV3.GetPasswordHashPRF(appUser.PasswordHash);
Password Hasher Utility.
Ken Haggerty
Created 10/24/24
Updated 10/24/24 20:46 GMT

Log In or Reset Quota to read more.

Article Tags:

Authentication Validation
Successfully completed. Thank you for contributing.
Processing...
Something went wrong. Please try again.
Contribute to enjoy content without advertisments.
You can contribute without registering.

Comments(0)

Loading...
Loading...

Not accepting new comments.

Submit your comment. Comments are moderated.

User Image.
DisplayedName - Member Since ?