lib/not_qwerty123/wordlist_manager.ex

defmodule NotQwerty123.WordlistManager do
  @moduledoc """
  Module to manage the common password list and handle password checks.

  The main function that NotQwerty123 performs is to check that the
  password or a variant of the password (in other words, even with minor changes)
  is not in the list of common passwords that this WordlistManager stores.

  By default, this common password list contains one file, which
  has a list of over 40,000 common passwords in it - this file
  was collated by [zxcvbn](https://github.com/dropbox/zxcvbn),
  a reputable password strength meter.

  Other files can be added to this common password list, and there
  are example files in the `priv/data` directory (these files are
  also taken from the zxcvbn repository).

  In order to make the password strength check even stronger, it
  is also recommended to add to the list words that are associated with
  the site you are managing.

  ## Managing the common password list

  The following functions can be used to manage this list:

    * `list_wordlists/0` - list the files used to create the wordlist
    * `push/1` - add a file to the wordlist
    * `pop/1` - remove a file from the wordlist

  """

  use GenServer

  @sub_dict %{
    "!" => ["i"],
    "@" => ["a"],
    "$" => ["s"],
    "%" => ["x"],
    "(" => ["c"],
    "[" => ["c"],
    "+" => ["t"],
    "|" => ["i", "l"],
    "0" => ["o"],
    "1" => ["i", "l"],
    "2" => ["z"],
    "3" => ["e"],
    "4" => ["a"],
    "5" => ["s"],
    "6" => ["g"],
    "7" => ["t"],
    "8" => ["b"],
    "9" => ["g"]
  }

  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init([]) do
    {:ok, create_list()}
  end

  @doc """
  Search the wordlist to see if the password is too common.

  If the password is greater than 24 characters long, this
  function returns false without performing any checks.
  """
  def query(password, word_len) when word_len < 25 do
    GenServer.call(__MODULE__, {:query, password})
  end

  def query(_, _), do: false

  @doc """
  List the files used to create the common password list.
  """
  def list_files, do: GenServer.call(__MODULE__, :list_files)

  @doc """
  Add a file to the common password list.

  `path` is the pathname of the file, which should contain one
  password on each line, that you want to include.

  The file is parsed and the words are added to the common password
  list. A copy of the file is also copied to the
  `not_qwerty123/priv/wordlists` directory.

  If adding the file results in a timeout error, try splitting
  the file into smaller files and adding them.
  """
  def push(path), do: GenServer.cast(__MODULE__, {:push, path})

  @doc """
  Remove a file from the common password list.

  `path` is the file name as it is printed out in the `list_files`
  function.
  """
  def pop(path), do: GenServer.cast(__MODULE__, {:pop, path})

  def handle_call({:query, password}, _from, state) do
    {:reply, run_check(state, password), state}
  end

  def handle_call(:list_files, _from, state) do
    {:reply, File.ls!(wordlist_dir()), state}
  end

  def handle_cast({:push, path}, state) do
    new_state =
      case File.read(path) do
        {:ok, words} ->
          Path.join(wordlist_dir(), Path.basename(path)) |> File.write(words)
          add_words(words) |> :sets.union(state)

        _ ->
          state
      end

    {:noreply, new_state}
  end

  def handle_cast({:pop, "common_passwords.txt"}, state) do
    {:noreply, state}
  end

  def handle_cast({:pop, path}, state) do
    new_state =
      case File.rm(Path.join(wordlist_dir(), path)) do
        :ok -> create_list()
        _ -> state
      end

    {:noreply, new_state}
  end

  def handle_info(_msg, state) do
    {:noreply, state}
  end

  defp wordlist_dir(), do: Application.app_dir(:not_qwerty123, ~w(priv wordlists))

  defp create_list do
    File.ls!(wordlist_dir())
    |> Enum.map(
      &(Path.join(wordlist_dir(), &1)
        |> File.read!()
        |> add_words)
    )
    |> :sets.union()
  end

  defp add_words(data) do
    data
    |> String.downcase()
    |> String.split("\n")
    |> Enum.flat_map(&list_alternatives/1)
    |> :sets.from_list()
  end

  defp run_check(wordlist, password) do
    words = list_alternatives(password)

    alternatives =
      words ++
        Enum.map(words, &String.slice(&1, 1..-1)) ++
        Enum.map(words, &String.slice(&1, 0..-2)) ++ Enum.map(words, &String.slice(&1, 1..-2))

    reversed = Enum.map(alternatives, &String.reverse(&1))
    Enum.any?(alternatives ++ reversed, &:sets.is_element(&1, wordlist))
  end

  defp list_alternatives(""), do: [""]

  defp list_alternatives(password) do
    for i <- substitute(password) |> product, do: Enum.join(i)
  end

  defp substitute(word) do
    for <<letter <- word>>, do: Map.get(@sub_dict, <<letter>>, [<<letter>>])
  end

  defp product([h]), do: for(i <- h, do: [i])

  defp product([h | t]) do
    for i <- h,
        j <- product(t),
        do: [i | j]
  end
end