defmodule ExDjango.Pbkdf2 do @moduledoc """ Module to handle django pbkdf2_sha256 and pbkdf2_sha512 authentication. Comeonin didn't want to support pbkdf2_sha256 so copied to here. Comeonin password hashes start with $ it will work with both Django and Comeonin password hashes. To generate a password hash, use the `hashpwsalt` function: ExDjango.Pbkdf2.hashpwsalt("hard to guess") ExDjango.Pbkdf2.hashpwsalt("hard to guess", 20_000, :sha256) To check the password against a password hash, use the `checkpw` function: ExDjango.Pbkdf2.checkpw("hard to guess", stored_hash) There is also a `dummy_checkpw`, which can be used to stop an attacker guessing a username by timing the responses. See the documentation for each function for more details. Most users will not need to use any of the other functions in this module. ## Pbkdf2 Pbkdf2 is a password-based key derivation function that uses a password, a variable-length salt and an iteration count and applies a pseudorandom function to these to produce a key. The original implementation used SHA-1 as the pseudorandom function, but this version uses HMAC-SHA-256. """ use Bitwise @max_length bsl(1, 32) - 1 @salt_length 12 @pbkdf2_rounds 20_000 @doc """ Generate a salt for use with the `hashpass` function. The minimum length of the salt is 16 and the maximum length is 1024. The default is 16. """ def gen_salt(salt_length \\ @salt_length) def gen_salt(salt_length) when salt_length in 12..1024 do :crypto.strong_rand_bytes(salt_length * 2) |> Base.url_encode64 |> String.replace(~r/[-|_|=]/, "") |> String.slice(0, salt_length) end def gen_salt(_) do raise ArgumentError, message: "The salt is the wrong length." end @doc """ Hash the password using pbkdf2_sha256. """ def hashpass(password, salt, rounds \\ @pbkdf2_rounds, algorithm \\ :sha256) do if is_binary(salt) do pbkdf2(algorithm, password, salt, rounds) |> format(salt, rounds, algorithm) else raise ArgumentError, message: "Wrong type. The salt needs to be a string." end end @doc """ Hash the password with a salt which is randomly generated. To change the complexity (and the time taken) of the password hash calculation, you need to change the value for `pbkdf2_rounds` in the config file. """ def hashpwsalt(password, rounds \\ @pbkdf2_rounds, algorithm \\ :sha256) do hashpass(password, gen_salt, rounds, algorithm) end defp format(hash, salt, rounds, algorithm) do "pbkdf2_#{algorithm}$#{rounds}$#{salt}$#{Base.encode64(hash)}" end @doc """ Check the password. The check is performed in constant time to avoid timing attacks. """ def checkpw(password, hash) when is_binary(password) and is_binary(hash) do case String.starts_with?(hash, "$") do # If hash starts with $ use Comeonin to check it true -> Comeonin.Pbkdf2.checkpw(password, hash) false -> case String.split(hash, "$") do [algorithm, rounds, salt, hash] -> pbkdf2(parse_algorithm(algorithm), password, salt, String.to_integer(rounds)) |> Base.encode64 |> Comeonin.Tools.secure_check(hash) _ -> false end end end def checkpw(_password, _hash) do raise ArgumentError, message: "Wrong type. The password and hash need to be strings." end @doc """ Perform a dummy check for a user that does not exist. This always returns false. The reason for implementing this check is in order to make user enumeration by timing responses more difficult. """ def dummy_checkpw do hashpwsalt("password") false end @doc """ Calculate pbkdf2 hash """ def pbkdf2(:sha256, password, salt, rounds), do: pbkdf2(:sha256, password, salt, rounds, 32) def pbkdf2(:sha512, password, salt, rounds), do: pbkdf2(:sha512, password, salt, rounds, 64) defp pbkdf2(_algorithm, _password, _salt, _rounds, length) when length > @max_length do raise ArgumentError, "length must be less than or equal to #{@max_length}" end defp pbkdf2(algorithm, password, salt, rounds, length) when byte_size(salt) in 12..1024 do pbkdf2(algorithm, password, salt, rounds, length, 1, [], 0) end defp pbkdf2(_algorithm, _password, _salt, _rounds, _length) do raise ArgumentError, message: "The salt is the wrong length." end defp pbkdf2(_algorithm, _password, _salt, _rounds, max_length, _block_index, acc, length) when length >= max_length do key = acc |> Enum.reverse |> IO.iodata_to_binary <> = key bin end defp pbkdf2(algorithm, password, salt, rounds, max_length, block_index, acc, length) do initial = :crypto.hmac(algorithm, password, <>) block = iterate(algorithm, password, rounds - 1, initial, initial) pbkdf2(algorithm, password, salt, rounds, max_length, block_index + 1, [block | acc], byte_size(block) + length) end defp iterate(_algorithm, _password, 0, _prev, acc), do: acc defp iterate(algorithm, password, round, prev, acc) do next = :crypto.hmac(algorithm, password, prev) iterate(algorithm, password, round - 1, next, :crypto.exor(next, acc)) end defp parse_algorithm(algorithm) do case String.split(algorithm, ["_", "-"]) do [_, "sha256"] -> :sha256 [_, "sha512"] -> :sha512 _ -> raise ArgumentError, message: "Unknown password algorithm." end end end