Search code examples
c#password-encryptionpassword-hashpbkdf2

pbkdf2 - How do I verify hashed user password (C#)?


When a User registers, I run the password they input through CreateHash() - see below.

I then get passed back a Dictionary<string, string> with the Hash and the Salt in it which I then store in the SQL database each as varchar(256).

When a User attempts to log in, I then retrieve the salt from the DB and pass it as well as the inputted password into GetHash() - see below

The hash that I am getting back does not match what is in the database.

What might I be doing wrong?

public class EncryptionHelper
    {
        public const int SALT_SIZE = 128;
        public const int HASH_SIZE = 128;
        public const int ITERATIONS = 100000;

        // On declaring a new password for example
        public static Dictionary<string, string> CreateHash(string input)
        {
            Dictionary<string, string> hashAndSalt = new Dictionary<string, string>();
            RNGCryptoServiceProvider provider = new RNGCryptoServiceProvider();
            byte[] salt = new byte[SALT_SIZE];
            provider.GetBytes(salt);

            Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(input, salt, ITERATIONS);
            hashAndSalt.Add("Hash", Encoding.Default.GetString(pbkdf2.GetBytes(HASH_SIZE)));
            hashAndSalt.Add("Salt", Encoding.Default.GetString(salt));
            return hashAndSalt;
        }

        // To check if Users password input is correct for example
        public static string GetHash(string saltString, string passwordString)
        {
            byte[] salt = Encoding.ASCII.GetBytes(saltString);

            Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passwordString, salt, ITERATIONS);
            return Encoding.Default.GetString(pbkdf2.GetBytes(HASH_SIZE));
        }
    }

Solution

  • I think you have two problems here.

    1. As the documentation for Encoding.Default states, the encoding this returns can vary between computers or even on a single computer. This probably isn't your issue here, but you should be deliberate when you choose an encoding.
    2. Most text encodings are not what I'd call "round trippable" for anything other than the text they are designed to encode.

    The second option is likely your problem here. Let's assume that Encoding.UTF8 is being used.

    Imagine that provider.GetBytes(salt) generates the following bytes (represented as hexadecimal):

    78 73 AB 7A 4F 61 E9 3E 8A 96
    

    We can convert it to a string and back using this code:

    byte[] salt = new byte[10];
    provider.GetBytes(salt);
    string saltText = Encoding.UTF8.GetString(salt);
    salt = Encoding.UTF8.GetBytes(saltText);
    

    Now let's look at the output as hexadecimal:

    78 73 EF BF BD 7A 4F 61 EF BF BD 3E EF BF BD EF BF BD
    

    What's happened? Why haven't we got the same thing out as we put in?

    Well, we've tried to interpret the bytes as UTF8, but some of it doesn't decode correctly into a string (because it's not UTF8-encoded text). This means that the string we get doesn't represent the binary data. We then try to convert the string back to binary data, and get a completely different result.

    To solve this, you need to use an encoding intended to accurately represent binary data. There are a couple of common options:

    1. Hexadecimal (as I used above).
    2. Base64

    Since the base64 option is built-in, let's use that.

    public static Dictionary<string, string> CreateHash(string input)
    {
        Dictionary<string, string> hashAndSalt = new Dictionary<string, string>();
        RNGCryptoServiceProvider provider = new RNGCryptoServiceProvider();
        byte[] salt = new byte[SALT_SIZE];
        provider.GetBytes(salt);
    
        Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(input, salt, ITERATIONS);
        hashAndSalt.Add("Hash", Convert.ToBase64String(pbkdf2.GetBytes(HASH_SIZE)));
        hashAndSalt.Add("Salt", Convert.ToBase64String(salt));
        return hashAndSalt;
    }
    
    // To check if Users password input is correct for example
    public static string GetHash(string saltString, string passwordString)
    {
        byte[] salt = Convert.FromBase64String(saltString);
    
        Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passwordString, salt, ITERATIONS);
        return Convert.ToBase64String(pbkdf2.GetBytes(HASH_SIZE));
    }
    

    And now, because we're using base64 rather than string encoding that can't handle the binary data, we get the same hash!

    If you want hexadecimal you can:

    • Use Convert.ToHexString and Convert.FromHexString, assuming you're using .NET 5 or newer.
    • Refer to these answers to implement methods to convert to and from hex if you're using an older version.

    My recommendation would be to store these values as binary data in the database instead of strings. You don't need to convert them to string and it makes more sense to keep them as binary.