Packages

Library for converting hex to / from the PGP word list: https://en.wikipedia.org/wiki/PGP_word_list

Current section

Files

Jump to
pgp_wordlist lib pgp_wordlist.ex
Raw

lib/pgp_wordlist.ex

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