lib/drupal_pass.ex

import Bitwise

defmodule DrupalPass do
  @moduledoc """
  Drupal Password Check
  Based on 'includes/password.inc'
  """

  @invalid "*0"

  @drupal_hash_count 15
  @drupal_min_hash_count 7
  @drupal_max_hash_count 30
  @drupal_hash_length 55

  @doc """
  equivalent CheckPassword

  DrupaPass.check_password(<password>, <stored_hash>)
  return true or false
  """
  def check_password(password, stored_hash) do
    if String.length(password) > 4096 do
      false
    else
      hash = crypt_password(password, stored_hash)

      if String.slice(hash, 0, 1) == "*" do
        false
      else
        hash === stored_hash
      end
    end
  end

  @doc """
  encrypted and encoded password
  """
  defp crypt_password(password, stored_hash) do
    if String.length(stored_hash) != @drupal_hash_length do
      @invalid
    else
      id = String.slice(stored_hash, 0, 3)
      shift_count = get_hash_shift_count(stored_hash)

      cond do
        id != "$S$" ->
          @invalid

        shift_count < @drupal_min_hash_count || shift_count > @drupal_max_hash_count ->
          @invalid

        true ->
          prefix = String.slice(stored_hash, 0, 12)
          hash = get_password_hash(password, stored_hash)
          encode = Encode64.encode(hash)

          String.slice(prefix <> encode, 0, @drupal_hash_length)
      end
    end
  end

  @doc """
  password encryption
  """
  def get_password_hash(password, stored_hash) do
    salt = get_salt(stored_hash)
    count = get_hash_count(stored_hash)

    get_hash(:sha512, password, salt, count)
    |> :binary.bin_to_list()
  end

  @doc """
  extract salt from stored hash
  """
  def get_salt(stored_hash) do
    String.slice(stored_hash, 4, 8)
  end

  @doc """
  loop hasing password with salt
  """
  def get_hash(algo, password, salt, count) do
    Enum.reduce(
      0..count,
      salt,
      fn _, acc -> :crypto.hash(algo, acc <> password) end
    )
  end

  @doc """
  hashing count
  """
  defp get_hash_count(stored_hash) do
    1 <<< get_hash_shift_count(stored_hash)
  end

  @doc """
  hasing shift count
  """
  defp get_hash_shift_count(stored_hash) do
    stored_hash
    |> String.at(3)
    |> Encode64.strpos()
  end

  """
  fot test enviornment
  """

  if Mix.env() == :test do
    def test_crypt_password(password, stored_hash) do
      crypt_password(password, stored_hash)
    end

    def test_get_hash_count(stored_hash) do
      get_hash_count(stored_hash)
    end

    def test_get_hash_shift_count(stored_hash) do
      get_hash_shift_count(stored_hash)
    end
  end
end