DEV Community

Cover image for Evolutive and robust password hashing using PBKDF2 in .NET
Anthony Simmon
Anthony Simmon

Posted on • Originally published at anthonysimmon.com

Evolutive and robust password hashing using PBKDF2 in .NET

PBKDF2 (Password-Based Key Derivation Function) is a key derivation function that is often used for password hashing. Password managers such as 1Password and Bitwarden rely on it. This is also how ASP.NET Core Identity stores user passwords.

It's easy to use improper parameters when using PBKDF2. Many .NET developers get inspired by articles written several years ago which are no longer up-to-date with the current security standards. I am writing this article in part to address this issue. I will provide references and recommendations from the Open Worldwide Application Security Project (OWASP) and the National Institute of Standards and Technology (NIST). This way, you will be able to adapt the code to the recommendations of recognized security organizations, even in the years to come.

The C# code you will see is partly inspired by ASP.NET Core Identity's source code. If you are not familiar with the PBKDF2 algorithm, you will find explanations in the following section. The code will be evolutive. By this I mean that you will be able to easily change the PBKDF2 parameters and maintain the ability to rehash the passwords already stored in your databases with the new parameters. Furthermore, it will be optimized to reduce memory allocation using .NET APIs based on Span<T>.

I am not a security specialist, so my vocabulary should be accessible to most. Let's begin with an introduction to PBKDF2. If you wish to skip to the final code, here it is.

Introduction to PBKDF2 and security recommendations

The .NET implementation of PBKDF2 accepts the following parameters:

  1. The password to hash provided by the user.
  2. A salt, ideally generated randomly and unique for each password.
  3. A pseudo-random function (a hash algorithm, for example, SHA-256).
  4. A number of iterations to perform on the provided pseudo-random function.

The result of PBKDF2 is what is called a derived key. The hashed password is the combination of the derived key and the salt. Both need to be stored securely. In C#, the code looks like this:

var password = "Correct Horse Battery Staple"u8;

const int keySize = 256 / 8;
var salt = RandomNumberGenerator.GetBytes(keySize);

const int iterations = 600000;
var key = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, keySize);
Enter fullscreen mode Exit fullscreen mode

In this example:

  • The password is converted to bytes using UTF-8 string literals.
  • The hash algorithm used is SHA-256.
  • A 32-byte salt is generated randomly and securely using RandomNumberGenerator. The size of the salt is equal to the size of the hash algorithm used (256 bits / 8 = 32 bytes).
  • The number of iterations is 600,000. The larger this number, the more time it will take to hash a password.

Why SHA-256? Because it is an algorithm considered secure in 2023 and it is also used by password managers such as 1Password and Bitwarden. They chose it because it can be executed relatively quickly on the client side in JavaScript, in a browser. This choice is explained in more detail in the 1Password Security Design White Paper. For a .NET backend, SHA-512 might also be used.

The number of iterations was chosen based on OWASP's recommendations. In 2023, it is recommended:

  • To use 600,000 (or more) iterations for SHA-256.
  • To use 210,000 (or more) iterations for SHA-512.

These numbers evolve over time and with technological advances. A few years ago, OWASP recommended 310,000 iterations for SHA-256, and in the beginning, only 1,000. Hence the importance of creating a hashing system that can evolve.

Regarding the length of the derived key and the salt, one can refer to the used hash algorithm as well as the recommendations from NIST, namely:

  • At least 128 bits (16 bytes) for the salt.
  • At least 112 bits (14 bytes) for the derived key.

Take the time to read the latest recommendations from OWASP and NIST to ensure that the parameters used are still considered secure.

Creating an evolutive password hashing .NET API

Now that we've covered the basics of PBKDF2, we can create a .NET API in C# that will allow us to hash passwords securely and evolutively.

The goals are as follows:

  • To enable you to change the PBKDF2 parameters at any time.
  • To be backward-compatible with passwords already hashed with old parameters.
  • To detect when a password needs to be rehashed with new parameters.
  • To be optimized to reduce the amount of memory allocation.

To achieve these objectives, we will take inspiration from ASP.NET Core Identity's PasswordHasher class. It incorporates a concept of hash versioning, allowing only the number of iterations to be modified.

It's worth noting that by default, ASP.NET Core Identity (version 8) uses PBKDF2 with SHA-512 and only 100,000 iterations, which is less than the OWASP recommendation.

Here is the skeleton of our API, which will be implemented in the following sections:

public static class BetterPasswordHasher
{
    public static string HashPassword(string password)
    {
        // TODO
    }

    public static PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword)
    {
        // TODO
    }
}

public enum PasswordVerificationResult
{
    Failed = 0,
    Success = 1,
    SuccessRehashNeeded = 2,
}
Enter fullscreen mode Exit fullscreen mode

With this API, it becomes easy to create an authentication method that verifies a user's password and rehashes it if necessary. Here is some pseudocode, again based on ASP.NET Core Identity:

var providedUsername = "<user input>";
var providedPassword = "<user input>";

// TODO Handle not found and other errors.
var hashedPassword = db.GetHashedPassword(providedUsername);
var result = BetterPasswordHasher.VerifyHashedPassword(hashedPassword, providedPassword);

if (result == PasswordVerificationResult.SuccessRehashNeeded)
{
    var newHashedPassword = BetterPasswordHasher.HashPassword(providedPassword);
    db.SetHashedPassword(providedUsername, newHashedPassword);
}

return result == PasswordVerificationResult.Failed ? Forbidden() : Ok();
Enter fullscreen mode Exit fullscreen mode

The next step is to implement a hashing versioning system, so you can change the PBKDF2 parameters at any time.

Creating a hashing versioning system

As we've seen previously, the secure parameters for PBKDF2 can change over time, particularly the number of iterations, the hash algorithm, the salt length, and the derived key length. For this reason, we'll create a hashing versioning system:

public static class BetterPasswordHasher
{
    // Always use distinct version IDs for new versions, ideally by incrementing the highest version ID.
    private const byte VersionId1 = 0x01;

    private const byte DefaultVersionId = VersionId2;

    // Always preserve all the versions that might have been used to hash passwords.
    // Do not modify an existing version unless you're sure that no password was hashed with it yet.
    private static readonly Dictionary<byte, PasswordHasherVersion> Versions = new()
    {
        [VersionId1] = new PasswordHasherVersion(HashAlgorithmName.SHA256, SaltSize: 256 / 8, KeySize: 256 / 8, Iterations: 600000),
    };

    public static string HashPassword(string password)
    {
        // TODO
    }

    public static PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword)
    {
        // TODO
    }

    private sealed record PasswordHasherVersion(HashAlgorithmName Algorithm, int SaltSize, int KeySize, int Iterations);
}
Enter fullscreen mode Exit fullscreen mode

This approach is technically the same as the one used by ASP.NET Core Identity, but it's much easier to introduce new parameters for PBKDF2. You simply create a new version of hashing with a distinct ID, add it to the version list, and modify the default version.

Going forward, in addition to storing the salt and the derived key, we must also store the version that was used. The format will be the same as that used by ASP.NET Core Identity, namely:

{versionId}.{salt}.{key}
Enter fullscreen mode Exit fullscreen mode

When we verify a password, we just need to read the first byte to know the version used and execute PBKDF2 with the corresponding parameters. Hence the importance of always preserving old hashing versions, so we don't lose the ability to verify user passwords.

Hashing passwords efficiently with PBKDF2 and span-based APIs

We can apply the algorithm presented in the introduction as follows:

public static string HashPassword(string password)
{
    ArgumentNullException.ThrowIfNull(password);

    // Newly hashed passwords will always use the default version.
    var version = Versions[DefaultVersionId];

    // If you create a new version that makes the output size bigger than 1024 bytes,
    // consider allocating an array instead or using the shared array pool instead:
    // https://learn.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1.shared?view=net-8.0
    var hashedPasswordByteCount = 1 + version.SaltSize + version.KeySize;
    Span<byte> hashedPasswordBytes = stackalloc byte[hashedPasswordByteCount];

    // Creating spans is cheap and allows us to use span-based cryptography APIs.
    var saltBytes = hashedPasswordBytes.Slice(start: 1, length: version.SaltSize);
    var keyBytes = hashedPasswordBytes.Slice(start: 1 + version.SaltSize, length: version.KeySize);

    // Write the version ID first, the salt and then the key.
    hashedPasswordBytes[0] = DefaultVersionId;
    RandomNumberGenerator.Fill(saltBytes);
    Rfc2898DeriveBytes.Pbkdf2(password, saltBytes, keyBytes, version.Iterations, version.Algorithm);

    return Convert.ToBase64String(hashedPasswordBytes);
}
Enter fullscreen mode Exit fullscreen mode

In this code, the only memory allocation occurs on the last line when encoding the resulting bytes into a Base64 string. The span of bytes we work on, which contains the hashing version ID, the salt, and the derived key, is allocated on the stack using stackalloc.

stackalloc can be used when we are sure that the requested size is less than 1024 bytes. As noted in the documentation, repeatedly allocating large blocks of memory on the stack can lead to StackOverflowException.

Verifying PBKDF2 hashed passwords efficiently with span-based APIs

Verifying a hashed password is slightly more complex than hashing it. When receiving a password to verify, we need to retrieve the previously hashed password, apply PBKDF2 with the same parameters, and finally compare the two derived keys.

Here is the code:

public static PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword)
{
    ArgumentNullException.ThrowIfNull(hashedPassword);
    ArgumentNullException.ThrowIfNull(providedPassword);

    // We can predict the number of bytes that will be decoded from the Base64 string,
    // to avoid allocating a byte array with "Convert.FromBase64String" and instead use span-based APIs.
    // Again, consider creating an array or using the shared array pool if the output size is bigger than 1024 bytes.
    var hashedPasswordByteCount = ComputeDecodedBase64ByteCount(hashedPassword);
    Span<byte> hashedPasswordBytes = stackalloc byte[hashedPasswordByteCount];

    if (!Convert.TryFromBase64String(hashedPassword, hashedPasswordBytes, out _))
    {
        // This shouldn't happen unless there's a mistake in how we compute the decoded Base64 byte count.
        return PasswordVerificationResult.Failed;
    }

    if (hashedPasswordBytes.Length == 0)
    {
        return PasswordVerificationResult.Failed;
    }

    var versionId = hashedPasswordBytes[0];
    if (!Versions.TryGetValue(versionId, out var version))
    {
        // This can only happen if a developer removes a version from the dictionary,
        // or if someone was able to tamper with the hashed password.
        return PasswordVerificationResult.Failed;
    }

    var expectedHashedPasswordLength = 1 + version.SaltSize + version.KeySize;
    if (hashedPasswordBytes.Length != expectedHashedPasswordLength)
    {
        // The hashed password length doesn't match the expected length for the given version.
        // This can only happen if a developer modified an existing used version or if the hashed password was tampered with.
        return PasswordVerificationResult.Failed;
    }

    var saltBytes = hashedPasswordBytes.Slice(start: 1, length: version.SaltSize);
    var expectedKeyBytes = hashedPasswordBytes.Slice(start: 1 + version.SaltSize, length: version.KeySize);

    // Same stackalloc size considerations as above.
    Span<byte> actualKeyBytes = stackalloc byte[version.KeySize];
    Rfc2898DeriveBytes.Pbkdf2(providedPassword, saltBytes, actualKeyBytes, version.Iterations, version.Algorithm);

    // This method prevents leaking timing information when comparing the two byte spans.
    if (!CryptographicOperations.FixedTimeEquals(expectedKeyBytes, actualKeyBytes))
    {
        return PasswordVerificationResult.Failed;
    }

    // It's the responsibility of the caller to rehash the password if needed.
    return versionId != DefaultVersionId
        ? PasswordVerificationResult.SuccessRehashNeeded
        : PasswordVerificationResult.Success;
}

private static int ComputeDecodedBase64ByteCount(string base64Str)
{
    // Base64 encodes three bytes by four characters, and there can be up to two padding characters.
    var characterCount = base64Str.Length;
    var paddingCount = 0;

    if (characterCount > 0)
    {
        if (base64Str[characterCount - 1] == '=')
        {
            paddingCount++;

            if (characterCount > 1 && base64Str[characterCount - 2] == '=')
            {
                paddingCount++;
            }
        }
    }

    return (characterCount * 3 / 4) - paddingCount;
}
Enter fullscreen mode Exit fullscreen mode

As noted in the comments, we leverage span-based APIs to avoid allocating memory on the heap. Then, we handle several error scenarios, some of which are very unlikely:

  • We miscomputed the number of bytes decoded from the Base64 string.
  • We stored a hashed password that is empty.
  • We stored a hashed password with a version that doesn't exist or has been removed.
  • The parameters of the version used have been modified.
  • The expected derived key does not match the calculated derived key, indicating that the entered password is incorrect.

An interesting point to mention is the use of CryptographicOperations.FixedTimeEquals to compare the two derived keys. A less experienced developer might use SequenceEqual or a simple loop to validate that the two spans are identical. However, these approaches can be vulnerable to timing attacks. This concept is very well explained in an article by Kevin Jones, a Senior Security Engineer at GitHub.

Conclusion

Ideally, I would recommend not handling and storing passwords yourself. It is preferable to use an identity provider (IdP), such as Azure AD B2C, Auth0, or FusionAuth. These systems are designed to manage your users' identity (including their passwords) so you don't have to. You could also use Single Sign-On with cloud providers.

You can find the complete BetterPasswordHasher class on GitHub. This API will allow you to hash passwords in .NET using PBKDF2 and with parameters recommended by OWASP and NIST. Should these recommendations change, you will be able to easily introduce a new default hashing version and rehash passwords already stored in your databases. Moreover, the implementation is optimized to reduce the amount of memory allocation, mainly thanks to stackalloc and APIs based on Span<T>.

If you have any questions, feel free to react in the comments or contact me on Twitter @asimmon971.

Top comments (0)