lib/leaked_passwords.ex

defmodule LeakedPasswords do
  @moduledoc """
  Documentation for LeakedPasswords.
  """

  alias HaveIBeenPwnedApi.Range

  @doc """
  Checks in HIBP if the SHA1 of the password has been leaked to the outside
  world.
  """
  def leaked?(password) when byte_size(password) > 0 do
    password
    |> hashed_password
    |> request_hashlist
    |> match_in_list
  end

  def leaked?(""), do: false

  def hashed_password(password),
    do:
      :crypto.hash(:sha, password)
      |> Base.encode16()

  defp request_hashlist(<<hash_head::bytes-size(5), hash_tail::bytes-size(35)>>) do
    with {:ok, %{body: result_tuple}} <- Range.get(hash_head), do: {hash_tail, result_tuple}
  end

  defp match_in_list({:error, _}), do: false
  defp match_in_list({hash, tuple}), do: binsearch(tuple, hash, 0, tuple_size(tuple) - 1)

  defp binsearch(_, _, lower_bound, upper_bound)
       when upper_bound < lower_bound,
       do: false

  defp binsearch(tuple, value, lower_bound, upper_bound) do
    {lower_bound, upper_bound}
    |> calculate_middle_index()
    |> get_elem(tuple)
    |> compare(value)
    |> recurse(tuple, value, lower_bound, upper_bound)
  end

  defp calculate_middle_index({lower, upper}), do: (lower + upper) |> div(2)
  defp get_elem(index, tuple), do: {index, elem(tuple, index)}

  defp compare({_, <<hit::bytes-size(35)>> <> ":" <> count}, hit), do: String.to_integer(count)

  defp compare({index, <<entry::bytes-size(35)>> <> _}, value),
    do: {index, evaluate(entry > value)}

  defp evaluate(true), do: :lower
  defp evaluate(false), do: :upper

  defp recurse(count, _, _, _, _) when is_integer(count), do: count

  defp recurse({index, :lower}, tuple, value, lower_bound, _),
    do: binsearch(tuple, value, lower_bound, index - 1)

  defp recurse({index, :upper}, tuple, value, _, upper_bound),
    do: binsearch(tuple, value, index + 1, upper_bound)
end