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

Time:10-14

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

CodePudding user response:

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.

CodePudding user response:

Encoding.Default.GetString() will interpret the byte array as if it already contains string data. This is not right; the array contains random bytes. Instead, you want to get the Base64-encoded version of the bytes. Using the base-64 encoded version will allow you to get back the same byte array for the salt.

I also suggest using a Tuple instead of a Dictionary, which will save a couple allocations:

public static (string salt, string hash) CreateHash(string input)
{
    var provider = new RNGCryptoServiceProvider();
    byte[] salt = new byte[SALT_SIZE];
    provider.GetBytes(salt);

    var pbkdf2 = new Rfc2898DeriveBytes(input, salt, ITERATIONS);
    var hash = pbkdf2.GetBytes(HASH_SIZE);

    var saltString = Convert.ToBase64String(salt);
    var hashString = Convert.ToBase64String(hash);
    return (saltString, hashString);
}

public static string GetHash(string saltString, string passwordString)
{
    byte[] salt = Convert.FromBase64String(saltString);       
    var pbkdf2 = new Rfc2898DeriveBytes(passwordString, salt, ITERATIONS);
    return Convert.ToBase64String(pbkdf2.GetBytes(HASH_SIZE));
}

public static bool ValidateLogin(string username, string password)
{ 
    //made up the GetUserCredential() method
    (string saltString, string hashString) cred = GetUserCredential(username);

    //add code here to return false if credential lookup fails

    return GetHash(cred.saltString, password) == cred.hashString;
}

See it work here:

https://dotnetfiddle.net/aiJnS2

CodePudding user response:

Encoding.Default might be an ANSI encoding which is prone to losing data since it is based on ANSI code pages. I'd use Base64 as text representation of the hash.

public static string GetHash(string saltString, string passwordString)
{
    var salt = Encoding.ASCII.GetBytes(saltString);

    var pbkdf2 = new Rfc2898DeriveBytes(passwordString, salt, ITERATIONS);
    return Convert.ToBase64String(pbkdf2.GetBytes(HASH_SIZE));
}

CodePudding user response:

It's because you gave two different salts to the two Rfc2898DeriveBytes instances.

In the CreateHash(), Encoding.Default.GetString(salt) tries to encode bytes to a string. It should be avoided because it may fail or convert bytes in an irreversible way.(In the GetHash(), Encoding.ASCII.GetBytes(saltString) will be different from the salt you generated before at CreateHash().)

Use an encoding such as the base16 or base64. See this question for the first.

  • Related