defmodule PgpWordlist do @words Application.get_env(:pgp_wordlist, :words) @moduledoc """ Toolset for working with the PGP word list. Allows converting hex to / from words. The word list itself belongs to PGP Corporation. """ @doc """ Converts a 2 digit hex string (with uppercase letters) and even / odd position into a word on the PGP word list. `pos` is the 0-based index of the hex. There are two words assigned to each hex value with `pos` determining which word to use based on if it is even or odd. ## Examples iex> PgpWordlist.hex_to_word("00", 0) {:ok, "aardvark"} iex> PgpWordlist.hex_to_word("00", 1) {:ok, "adroitness"} iex> PgpWordlist.hex_to_word("00", 2) {:ok, "aardvark"} iex> PgpWordlist.hex_to_word("FF", 0) {:ok, "Zulu"} iex> PgpWordlist.hex_to_word("FF", 1) {:ok, "Yucatan"} iex> PgpWordlist.hex_to_word("FF", 33) {:ok, "Yucatan"} iex> PgpWordlist.hex_to_word("1", 1) {:error, "Hex must be 2 bytes long ('01' instead of '1')"} iex> PgpWordlist.hex_to_word("XX", 1) {:error, "Not valid hex"} """ @spec hex_to_word(String.t(), non_neg_integer) :: {:ok, String.t()} | {:error, String.t()} def hex_to_word(hex, pos) def hex_to_word(hex, _pos) when is_binary(hex) and byte_size(hex) == 1 do {:error, "Hex must be 2 bytes long ('01' instead of '1')"} end @doc """ Converts a word to a hex based on the PGP word list. Will handle different cases of words and return hex with uppercase letters. ## Examples iex> PgpWordlist.word_to_hex("AARDVARK") {:ok, "00"} iex> PgpWordlist.word_to_hex("aardvark") {:ok, "00"} iex> PgpWordlist.word_to_hex("AardVaRk") {:ok, "00"} iex> PgpWordlist.word_to_hex("woodlark") {:ok, "FE"} iex> PgpWordlist.word_to_hex("Windsor") {:error, "Word does not exist in PGP specification"} """ @spec word_to_hex(String.t()) :: {:ok, String.t()} | {:error, String.t()} def word_to_hex(word) # Lower the word just to simplify things def word_to_hex(word), do: lowered_word_to_hex(String.downcase(word)) # To increase performance and reduce the amount of typing we have to do # we use the for comprehension to generate different functions for us. # # Each function pattern matches a different word / hex and returns the appropriate item. # Guards run the correct function based on if the position is even or odd. for {{word_a, word_b}, number} <- Enum.with_index(@words) do hex = number |> Integer.to_string(16) |> String.pad_leading(2, "0") def hex_to_word(unquote(hex), pos) when rem(pos, 2) == 0, do: {:ok, unquote(word_a)} def hex_to_word(unquote(hex), pos) when rem(pos, 2) == 1, do: {:ok, unquote(word_b)} # To account for a user we only accept the lowered version of the word. defp lowered_word_to_hex(unquote(String.downcase(word_a))), do: {:ok, unquote(hex)} defp lowered_word_to_hex(unquote(String.downcase(word_b))), do: {:ok, unquote(hex)} end # Generic error matchers for the auto-generated functions def hex_to_word(_hex, _pos), do: {:error, "Not valid hex"} defp lowered_word_to_hex(_word), do: {:error, "Word does not exist in PGP specification"} @doc """ Converts an even length hex string to list of words Any errors encountered during converting any item will be returned immediately without aggregation ## Examples iex> PgpWordlist.hex_to_words("00") {:ok, ["aardvark"]} iex> PgpWordlist.hex_to_words("E582") {:ok, ["topmost", "Istanbul"]} iex> PgpWordlist.hex_to_words("82E5") {:ok, ["miser", "travesty"]} iex> PgpWordlist.hex_to_words("82XXE5") {:error, "Not valid hex"} iex> PgpWordlist.hex_to_words("") {:ok, []} iex> PgpWordlist.hex_to_words("00F") {:error, "Hex must be an even amount of bytes long"} """ @spec hex_to_words(String.t()) :: {:ok, list(String.t())} | {:error, String.t()} def hex_to_words(hex) def hex_to_words(hex) when is_binary(hex) and rem(byte_size(hex), 2) == 1 do {:error, "Hex must be an even amount of bytes long"} end def hex_to_words(hex) when is_binary(hex) and rem(byte_size(hex), 2) == 0 do hex # Split the string into characters |> String.split("", trim: true) # Groups of two characters |> Stream.chunk_every(2) # We need to join these two characters back together |> Stream.map(&Enum.join/1) # Associate each one with an index |> Stream.with_index() # Finally call our other function to convert it |> Stream.map(fn {h, p} -> hex_to_word(h, p) end) # Reduce into an list inside an ok|error tuple # Give up at the first error and return that |> Enum.reduce_while({:ok, []}, fn {:ok, term}, {:ok, acc} -> {:cont, {:ok, [term | acc]}} {:error, _error} = error, _acc -> {:halt, error} end) # The list needs to be reversed inside the tuple |> maybe_reverse_list end @spec maybe_reverse_list({:ok, list(String.t())} | {:error, String.t()}) :: {:ok, list(String.t())} | {:error, String.t()} defp maybe_reverse_list(data) do case data do {:ok, items} -> {:ok, Enum.reverse(items)} {:error, _} = error -> error end end @doc """ Converts a list of words to hex based on the PGP word list Any errors encountered during converting any item will be returned immediately without aggregation ## Examples iex> PgpWordlist.words_to_hex(["topmost", "Istanbul", "Pluto", "vagabond"]) {:ok, "E58294F2"} iex> PgpWordlist.words_to_hex(["miser", "travesty"]) {:ok, "82E5"} iex> PgpWordlist.words_to_hex([]) {:ok, ""} iex> PgpWordlist.words_to_hex(["Windsor"]) {:error, "Word does not exist in PGP specification"} iex> PgpWordlist.words_to_hex(["miser", "Windsor"]) {:error, "Word does not exist in PGP specification"} """ @spec words_to_hex(list(String.t())) :: String.t() def words_to_hex(words) def words_to_hex(words) when is_list(words) do words # Convert each item |> Stream.map(&word_to_hex/1) # Reduce into a string inside an ok|error tuple # Give up at the first error and return that |> Enum.reduce_while({:ok, ""}, fn {:ok, term}, {:ok, acc} -> {:cont, {:ok, acc <> term}} {:error, _error} = error, _acc -> {:halt, error} end) end end