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) "aardvark" iex> PgpWordlist.hex_to_word("00", 1) "adroitness" iex> PgpWordlist.hex_to_word("00", 2) "aardvark" iex> PgpWordlist.hex_to_word("FF", 0) "Zulu" iex> PgpWordlist.hex_to_word("FF", 1) "Yucatan" iex> PgpWordlist.hex_to_word("FF", 33) "Yucatan" iex> PgpWordlist.hex_to_word("1", 1) ** (ArgumentError) Hex must be 2 bytes long ('01' instead of '1') """ @spec hex_to_word(binary, non_neg_integer) :: binary def hex_to_word(hex, pos) def hex_to_word(hex, _pos) when is_binary(hex) and byte_size(hex) == 1 do raise(ArgumentError, "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") "00" iex> PgpWordlist.word_to_hex("aardvark") "00" iex> PgpWordlist.word_to_hex("AardVaRk") "00" iex> PgpWordlist.word_to_hex("woodlark") "FE" """ @spec word_to_hex(binary) :: binary 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: unquote(word_a) def hex_to_word(unquote(hex), pos) when rem(pos, 2) == 1, do: 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: unquote(hex) defp lowered_word_to_hex(unquote(String.downcase(word_b))), do: unquote(hex) end @doc """ Converts an even length hex string to list of words ## Examples iex> PgpWordlist.hex_to_words("00") ["aardvark"] iex> PgpWordlist.hex_to_words("E582") ["topmost", "Istanbul"] iex> PgpWordlist.hex_to_words("82E5") ["miser", "travesty"] iex> PgpWordlist.hex_to_words("") [] iex> PgpWordlist.hex_to_words("00F") ** (ArgumentError) Hex must be an even amount of bytes long """ @spec hex_to_words(binary) :: list(binary()) def hex_to_words(hex) def hex_to_words(hex) when is_binary(hex) and rem(byte_size(hex), 2) == 1 do raise(ArgumentError, "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 |> Enum.map(fn {h, p} -> hex_to_word(h, p) end) end @doc """ Converts a list of words to hex based on the PGP word list ## Examples iex> PgpWordlist.words_to_hex(["topmost", "Istanbul", "Pluto", "vagabond"]) "E58294F2" iex> PgpWordlist.words_to_hex(["miser", "travesty"]) "82E5" iex> PgpWordlist.words_to_hex([]) "" """ @spec words_to_hex(list(binary)) :: binary def words_to_hex(words) def words_to_hex(words) when is_list(words) do words # Go through each one and convert the word back to a hex value |> Enum.map(&word_to_hex/1) # Join all the hexs together |> Enum.join() end end