aboutsummaryrefslogtreecommitdiff
path: root/shard
diff options
context:
space:
mode:
Diffstat (limited to 'shard')
-rw-r--r--shard/config/config.exs6
-rw-r--r--shard/lib/app/chat.ex77
-rw-r--r--shard/lib/app/identity.ex113
-rw-r--r--shard/lib/application.ex2
-rw-r--r--shard/lib/cli/cli.ex109
-rw-r--r--shard/lib/identity.ex65
-rw-r--r--shard/lib/keys.ex124
-rw-r--r--shard/lib/manager.ex24
-rw-r--r--shard/lib/net/tcpconn.ex2
-rw-r--r--shard/mix.exs1
-rw-r--r--shard/mix.lock5
11 files changed, 399 insertions, 129 deletions
diff --git a/shard/config/config.exs b/shard/config/config.exs
index f2a7f09..a5aa071 100644
--- a/shard/config/config.exs
+++ b/shard/config/config.exs
@@ -32,6 +32,12 @@ use Mix.Config
# it is not recommended to use long suffixes.
config :shard, peer_id_suffix: "SH"
+# Identity suffix
+# ===============
+# This Shard instance will only accept messages by identities whose
+# key ends by this suffix
+config :shard, identity_suffix: "ID"
+
# Data directory
# ==============
config :shard, data_path: Path.join [System.user_home, "shard", "data"]
diff --git a/shard/lib/app/chat.ex b/shard/lib/app/chat.ex
index db2cb64..e61d648 100644
--- a/shard/lib/app/chat.ex
+++ b/shard/lib/app/chat.ex
@@ -28,7 +28,7 @@ defmodule SApp.Chat do
defimpl Shard.Manifest, for: Manifest do
def start(m) do
- SApp.Chat.start_link(m.channel)
+ DynamicSupervisor.start_child(Shard.DynamicSupervisor, {SApp.Chat, m.channel})
end
end
@@ -69,6 +69,12 @@ defmodule SApp.Chat do
end
end
+ def find_proc(chan) do
+ manifest = %Manifest{channel: chan}
+ id = SData.term_hash manifest
+ Shard.Manager.find_proc id
+ end
+
@doc """
Implementation of the :manifest call that returns the chat room's manifest
"""
@@ -98,10 +104,11 @@ defmodule SApp.Chat do
to send a message to the chat room. Puts the message in the store and syncs
with all connected peers.
"""
- def handle_cast({:chat_send, msg}, state) do
- msgitem = {(System.os_time :seconds),
- Shard.Identity.get_nickname(),
- msg}
+ def handle_cast({:chat_send, pk, msg}, state) do
+ msgbin = SData.term_bin {(System.os_time :seconds), msg}
+ {:ok, sign} = Shard.Keys.sign_detached(pk, msgbin)
+ msgitem = {pk, msgbin, sign}
+
prev_root = state.mst.root
mst = MST.insert(state.mst, msgitem)
state = %{state | mst: mst}
@@ -157,21 +164,26 @@ defmodule SApp.Chat do
state
else
# Try adding the message
- if prev_root == state.mst.root do
- mst2 = MST.insert(state.mst, msgitem)
- if mst2.root == new_root do
- # This was the only message missing, we are happy!
- state = %{state | mst: mst2}
- Shard.Manager.save_state(state.id, mst2.root)
- GenServer.cast(state.page_store, {:set_roots, [mst2.root]})
- msg_callback(state, msgitem)
- state
+ {pk, bin, sign} = msgitem
+ if Shard.Keys.verify(pk, bin, sign) == :ok do
+ if prev_root == state.mst.root do
+ mst2 = MST.insert(state.mst, msgitem)
+ if mst2.root == new_root do
+ # This was the only message missing, we are happy!
+ state = %{state | mst: mst2}
+ Shard.Manager.save_state(state.id, mst2.root)
+ GenServer.cast(state.page_store, {:set_roots, [mst2.root]})
+ msg_callback(state, msgitem)
+ state
+ else
+ Logger.warn("Invalid new root after inserting same message item!")
+ state
+ end
else
- # More messages are missing, start a full merge
init_merge(state, new_root, peer_id)
end
else
- init_merge(state, new_root, peer_id)
+ state
end
end
{:root, new_root} ->
@@ -190,21 +202,33 @@ defmodule SApp.Chat do
defp init_merge(state, new_root, source_peer) do
# TODO: make the merge asynchronous
+
+ Logger.info("Starting merge for #{inspect state.manifest}, merging root: #{new_root|>Base.encode16}")
+
prev_last = for {x, true} <- MST.last(state.mst, nil, 100), into: MapSet.new, do: x
mgmst = %{state.mst | root: new_root}
mgmst = put_in(mgmst.store.prefer_ask, [source_peer])
mst = MST.merge(state.mst, mgmst)
- for {x, true} <- MST.last(mst, nil, 100) do
+ correct = for {x, true} <- MST.last(mst, nil, 100) do
if not MapSet.member? prev_last, x do
msg_callback(state, x)
+ {pk, bin, sign} = x
+ Shard.Keys.verify(pk, bin, sign)
+ else
+ true
end
end
- GenServer.cast(state.page_store, {:set_roots, [mst.root]})
- Shard.Manager.save_state(state.id, mst.root)
- %{state | mst: mst}
+ if Enum.all? correct do
+ GenServer.cast(state.page_store, {:set_roots, [mst.root]})
+ Shard.Manager.save_state(state.id, mst.root)
+ %{state | mst: mst}
+ else
+ Logger.warn("Incorrect signatures somewhere while merging, dropping merged data")
+ state
+ end
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
@@ -212,20 +236,23 @@ defmodule SApp.Chat do
{:noreply, %{ state | subs: new_subs }}
end
- defp msg_callback(state, {ts, nick, msg}) do
+ defp msg_callback(state, {pk, msgbin, _sign}) do
+ {ts, msg} = SData.term_unbin msgbin
for pid <- state.subs do
if Process.alive?(pid) do
- send(pid, {:chat_recv, state.channel, {ts, nick, msg}})
+ send(pid, {:chat_recv, state.channel, {ts, pk, msg}})
end
end
end
- defp msg_cmp({ts1, nick1, msg1}, {ts2, nick2, msg2}) do
+ defp msg_cmp({pk1, msgbin1, _sign1}, {pk2, msgbin2, _sign2}) do
+ {ts1, msg1} = SData.term_unbin msgbin1
+ {ts2, msg2} = SData.term_unbin msgbin2
cond do
ts1 > ts2 -> :after
ts1 < ts2 -> :before
- nick1 > nick2 -> :after
- nick1 < nick2 -> :before
+ pk1 > pk2 -> :after
+ pk1 < pk2 -> :before
msg1 > msg2 -> :after
msg1 < msg2 -> :before
true -> :duplicate
diff --git a/shard/lib/app/identity.ex b/shard/lib/app/identity.ex
new file mode 100644
index 0000000..7e97897
--- /dev/null
+++ b/shard/lib/app/identity.ex
@@ -0,0 +1,113 @@
+defmodule SApp.Identity do
+ use GenServer
+
+ require Logger
+
+ defmodule Manifest do
+ defstruct [:pk]
+ end
+
+ defmodule State do
+ defstruct [:info, :rev, :signed]
+ end
+
+ defimpl Shard.Manifest, for: Manifest do
+ def start(m) do
+ DynamicSupervisor.start_child(Shard.DynamicSupervisor, {SApp.Identity, m.pk})
+ end
+ end
+
+ def start_link(pk) do
+ GenServer.start_link(__MODULE__, pk)
+ end
+
+ def init(pk) do
+ manifest = %Manifest{pk: pk}
+ id = SData.term_hash manifest
+
+ case Shard.Manager.register(id, manifest, self()) do
+ :ok ->
+ Shard.Manager.dispatch_to(id, nil, self())
+ state = case Shard.Manager.load_state(id) do
+ nil ->
+ info = %{nick: default_nick(pk)}
+ SData.SignRev.new info
+ st ->
+ st
+ end
+ GenServer.cast(self(), :init_pull)
+ {:ok, %{pk: pk, id: id, state: state}}
+ :redundant ->
+ exit(:redundant)
+ end
+ end
+
+ def default_nick(pk) do
+ nick_suffix = pk
+ |> binary_part(0, 4)
+ |> Base.encode16
+ |> String.downcase
+ "Anon" <> nick_suffix
+ end
+
+ def find_proc(pk) do
+ manifest = %Manifest{pk: pk}
+ id = SData.term_hash manifest
+ Shard.Manager.find_proc id
+ end
+
+ def handle_call(:manifest, _from, state) do
+ {:replyl, state.manifest, state}
+ end
+
+ def handle_call(:get_info, _from, state) do
+ {:reply, SData.SignRev.get(state.state), state}
+ end
+
+ def handle_call({:set_info, new_info}, _from, state) do
+ case SData.SignRev.set(state.state, new_info, state.pk) do
+ {:ok, st2} ->
+ Shard.Manager.save_state(state.id, st2)
+ state = put_in(state.state, st2)
+ bcast_state(state)
+ {:reply, :ok, state}
+ err ->
+ {:reply, err, state}
+ end
+ end
+
+ def handle_cast(:init_pull, state) do
+ for {_, pid, _, _} <- Shard.Manager.list_peers do
+ GenServer.cast(pid, {:send_msg, {:interested, [state.id]}})
+ end
+ {:noreply, state}
+ end
+
+ def handle_cast({:interested, peer_id}, state) do
+ Shard.Manager.send(peer_id, {state.id, nil, {:update, SData.SignRev.signed(state.state)}})
+ end
+
+ def handle_cast({:msg, peer_id, _shard_id, nil, msg}, state) do
+ state = case msg do
+ {:update, signed} ->
+ case SData.SignRev.merge(state.state, signed, state.pk) do
+ {true, st2} ->
+ Shard.Manager.save_state(state.id, st2)
+ state = put_in(state.state, st2)
+ bcast_state(state, [peer_id])
+ state
+ {false, _} ->
+ state
+ end
+ end
+ {:noreply, state}
+ end
+
+ def bcast_state(state, exclude \\ []) do
+ for peer_id <- Shard.Manager.get_shard_peers(state.id) do
+ if not Enum.member? exclude, peer_id do
+ Shard.Manager.send(peer_id, {state.id, nil, {:update, SData.SignRev.signed(state.state)}})
+ end
+ end
+ end
+end
diff --git a/shard/lib/application.ex b/shard/lib/application.ex
index 3e3a6ac..9c1577a 100644
--- a/shard/lib/application.ex
+++ b/shard/lib/application.ex
@@ -15,7 +15,7 @@ defmodule Shard.Application do
# Define workers and child supervisors to be supervised
children = [
- Shard.Identity,
+ Shard.Keys,
{ DynamicSupervisor, strategy: :one_for_one, name: Shard.DynamicSupervisor },
# Networking
diff --git a/shard/lib/cli/cli.ex b/shard/lib/cli/cli.ex
index bf3a555..3778b2d 100644
--- a/shard/lib/cli/cli.ex
+++ b/shard/lib/cli/cli.ex
@@ -3,22 +3,45 @@ defmodule SCLI do
Small command line interface for the chat application
"""
+ defmodule State do
+ defstruct [:room_pid, :id_pid, :pk]
+ end
+
def run() do
- for {_chid, _manifest, chpid} <- Shard.Manager.list_shards do
+ for {_chid, %SApp.Chat.Manifest{}, chpid} <- Shard.Manager.list_shards do
GenServer.cast(chpid, {:subscribe, self()})
end
- run(nil)
+ pk = case Shard.Keys.list_identities do
+ [pk1|_] -> pk1
+ _ ->
+ IO.puts "Generating a new identity..."
+ Shard.Keys.new_identity
+ end
+
+ run(%State{room_pid: nil, id_pid: nil, pk: pk})
end
- defp run(pid) do
+ defp run(state) do
handle_messages()
- nick = Shard.Identity.get_nickname
- prompt = case pid do
+ id_pid = case state.id_pid do
+ nil -> SApp.Identity.find_proc(state.pk)
+ x -> x
+ end
+ state = put_in(state.id_pid, id_pid)
+
+ nick = case id_pid do
+ nil -> SApp.Identity.default_nick(state.pk)
+ _ ->
+ info = GenServer.call(id_pid, :get_info)
+ info.nick
+ end
+
+ prompt = case state.room_pid do
nil -> "(no channel) #{nick}: "
_ ->
- %SApp.Chat.Manifest{channel: chan} = GenServer.call(pid, :manifest)
+ %SApp.Chat.Manifest{channel: chan} = GenServer.call(state.room_pid, :manifest)
"##{chan} #{nick}: "
end
@@ -28,13 +51,13 @@ defmodule SCLI do
nil
String.slice(str, 0..0) == "/" ->
command = str |> String.slice(1..-1) |> String.split(" ")
- pid2 = handle_command(pid, command)
- run(pid2)
+ state = handle_command(state, command)
+ run(state)
true ->
if str != "" do
- GenServer.cast(pid, {:chat_send, str})
+ GenServer.cast(state.room_pid, {:chat_send, state.pk, str})
end
- run(pid)
+ run(state)
end
end
@@ -50,57 +73,71 @@ defmodule SCLI do
end
end
- defp handle_command(pid, ["connect", ipstr, portstr]) do
+ defp handle_command(state, ["connect", ipstr, portstr]) do
{:ok, ip} = :inet.parse_address (to_charlist ipstr)
{port, _} = Integer.parse portstr
Shard.Manager.add_peer(ip, port)
- pid
+ state
end
- defp handle_command(pid, ["list"]) do
+ defp handle_command(state, ["list"]) do
IO.puts "List of known channels:"
- for {_chid, manifest, _chpid} <- Shard.Manager.list_shards do
- %SApp.Chat.Manifest{channel: chan} = manifest
+ for {_chid, %SApp.Chat.Manifest{channel: chan}, _chpid} <- Shard.Manager.list_shards do
IO.puts "##{chan}"
end
- pid
+ state
end
- defp handle_command(pid, ["hist"]) do
- if pid == nil do
+ defp handle_command(state, ["hist"]) do
+ if state.room_pid == nil do
IO.puts "Not currently on a channel!"
else
- GenServer.call(pid, {:read_history, nil, 25})
- |> Enum.each(fn {{ts, nick, msg}, true} ->
- IO.puts "#{ts |> DateTime.from_unix! |> DateTime.to_iso8601} <#{nick}> #{msg}"
+ GenServer.call(state.room_pid, {:read_history, nil, 25})
+ |> Enum.each(fn {{pk, msgbin, _sign}, true} ->
+ {ts, msg} = SData.term_unbin msgbin
+ nick = case SApp.Identity.find_proc pk do
+ nil ->
+ SApp.Identity.default_nick pk
+ pid ->
+ info = GenServer.call(pid, :get_info)
+ info.nick
+ end
+ IO.puts "#{ts |> DateTime.from_unix! |> DateTime.to_iso8601} <#{nick} #{pk|>binary_part(0, 4)|>Base.encode16|>String.downcase}> #{msg}"
end)
- pid
end
+ state
end
- defp handle_command(_pid, ["join", qchan]) do
- list = for {_chid, manifest, chpid} <- Shard.Manager.list_shards,
- %SApp.Chat.Manifest{channel: chan} = manifest,
- do: {chan, chpid}
- case List.keyfind(list, qchan, 0) do
+ defp handle_command(state, ["join", qchan]) do
+ pid = SApp.Chat.find_proc qchan
+ case pid do
nil ->
- {:ok, pid} = DynamicSupervisor.start_child(Shard.DynamicSupervisor, {SApp.Chat, qchan})
+ {:ok, pid} = Shard.Manifest.start %SApp.Chat.Manifest{channel: qchan}
GenServer.cast(pid, {:subscribe, self()})
- pid
- {_, pid} ->
+ %{state | room_pid: pid}
+ pid ->
IO.puts "Switching to ##{qchan}"
- pid
+ %{state | room_pid: pid}
end
end
- defp handle_command(pid, ["nick", nick]) do
- Shard.Identity.set_nickname nick
- pid
+ defp handle_command(state, ["nick", nick]) do
+ pid = case state.id_pid do
+ nil -> SApp.Identity.find_proc state.pk
+ x -> x
+ end
+ if pid == nil do
+ IO.puts "Sorry, we have a problem with the identity shard"
+ else
+ info = GenServer.call(pid, :get_info)
+ GenServer.call(pid, {:set_info, %{info | nick: nick}})
+ end
+ state
end
- defp handle_command(pid, _cmd) do
+ defp handle_command(state, _cmd) do
IO.puts "Invalid command"
- pid
+ state
end
end
diff --git a/shard/lib/identity.ex b/shard/lib/identity.ex
deleted file mode 100644
index 01b3c9e..0000000
--- a/shard/lib/identity.ex
+++ /dev/null
@@ -1,65 +0,0 @@
-defmodule Shard.Identity do
- use Agent
- require Salty.Sign.Ed25519, as: Sign
- require Logger
-
- @identity_db [Application.get_env(:shard, :data_path), "identity_db"] |> Path.join |> String.to_atom
-
- def start_link(_) do
- Agent.start_link(__MODULE__, :init, [], name: __MODULE__)
- end
-
- def init() do
- :dets.start
- {:ok, @identity_db} = :dets.open_file @identity_db, type: :set
-
- case :dets.match @identity_db, :"$1" do
- [] ->
- Logger.info "Generating keypair..."
- {pk, sk} = gen_keypair(Application.get_env(:shard, :peer_id_suffix))
- nick_suffix = pk
- |> binary_part(0, 3)
- |> Base.encode16
- |> String.downcase
- nick = "Anon" <> nick_suffix
- :dets.insert @identity_db, {pk, sk, nick}
- %{
- keypair: {pk, sk},
- nickname: nick
- }
- [[{pk, sk, nick}] | _] ->
- %{
- keypair: {pk, sk},
- nickname: nick
- }
- end
- end
-
- defp gen_keypair(suffix, n \\ 0) do
- {:ok, pk, sk} = Sign.keypair
- if rem(n, 10000) == 0 do
- Logger.info "#{n}... expected #{:math.pow(256, byte_size(suffix))}"
- end
- if :binary.longest_common_suffix([pk, suffix]) == byte_size(suffix) do
- {pk, sk}
- else
- gen_keypair(suffix, n+1)
- end
- end
-
- def get_keypair() do
- Agent.get(__MODULE__, &(&1.keypair))
- end
-
- def get_nickname() do
- Agent.get(__MODULE__, &(&1.nickname))
- end
-
- def set_nickname(newnick) do
- Agent.update(__MODULE__, fn state ->
- {pk, sk} = state.keypair
- :dets.insert @identity_db, {pk, sk, newnick}
- %{state | nickname: newnick}
- end)
- end
-end
diff --git a/shard/lib/keys.ex b/shard/lib/keys.ex
new file mode 100644
index 0000000..0dc3154
--- /dev/null
+++ b/shard/lib/keys.ex
@@ -0,0 +1,124 @@
+defmodule Shard.Keys do
+ @moduledoc"""
+ Module for saving private keys.
+ """
+
+ use Agent
+ require Salty.Sign.Ed25519, as: Sign
+ require Logger
+
+ @key_db [Application.get_env(:shard, :data_path), "key_db"] |> Path.join |> String.to_atom
+
+ def start_link(_) do
+ Agent.start_link(__MODULE__, :init, [], name: __MODULE__)
+ end
+
+ def init() do
+ :dets.start
+ {:ok, @key_db} = :dets.open_file(@key_db, [type: :set])
+
+ case :dets.lookup(@key_db, :peer) do
+ [] ->
+ Logger.info "Generating peer keypair..."
+ {pk, sk} = gen_keypair(Application.get_env(:shard, :peer_id_suffix))
+ :dets.insert @key_db, {:peer, pk, sk}
+ {pk, sk}
+ [{:peer, pk, sk}] ->
+ {pk, sk}
+ end
+ end
+
+ defp gen_keypair(suffix, n \\ 0) do
+ {:ok, pk, sk} = Sign.keypair
+ if rem(n, 10000) == 0 do
+ Logger.info "#{n}... expected #{:math.pow(256, byte_size(suffix))}"
+ end
+ if check_suffix(pk, suffix) do
+ {pk, sk}
+ else
+ gen_keypair(suffix, n+1)
+ end
+ end
+
+ defp check_suffix(pk, suffix) do
+ :binary.longest_common_suffix([pk, suffix]) == byte_size(suffix)
+ end
+
+ def get_peer_keypair() do
+ Agent.get(__MODULE__, &(&1))
+ end
+
+ @doc"""
+ Generate a new keypair for a user identity, and start an Identity Shard for it.
+ """
+ def new_identity() do
+ {pk, sk} = gen_keypair(Application.get_env(:shard, :identity_suffix))
+ :dets.insert @key_db, {pk, sk}
+ SApp.Identity.start_link(pk)
+ pk
+ end
+
+ @doc"""
+ List the public keys of all identities for which we have a secret key
+ """
+ def list_identities() do
+ for [{pk, _sk}] <- :dets.match(@key_db, :"$1"), do: pk
+ end
+
+ @doc"""
+ Lookup the secret key for a pk and sign a message with it.
+
+ Returns the input value alongside its signature.
+
+ Answer is {:ok, signed} if it worked, or :not_found if we didn't find the key.
+ """
+ def sign(pk, bin) do
+ case :dets.lookup @key_db, pk do
+ [{^pk, sk}] ->
+ Sign.sign(bin, sk)
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc"""
+ Checks the signature appended to a signed message corresponds to a public key.
+
+ If correct, returns {:ok, original_message}
+ """
+ def open(pk, signed) do
+ if check_suffix(pk, Application.get_env(:shard, :identity_suffix)) do
+ Sign.open(signed, pk)
+ else
+ {:error, :invalid_pk_suffix}
+ end
+ end
+
+ @doc"""
+ Lookup the secret key for a pk and generate a detached signature for a message.
+
+ The original message is not returned.
+
+ Answer is {:ok, signature} if it worked, or :not_found if we didn't find the key.
+
+ """
+ def sign_detached(pk, bin) do
+ case :dets.lookup @key_db, pk do
+ [{^pk, sk}] ->
+ Sign.sign_detached(bin, sk)
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc"""
+ Verify a detached signature for a message
+
+ Returns :ok if the signature was correct.
+ """
+ def verify(pk, bin, sign) do
+ if check_suffix(pk, Application.get_env(:shard, :identity_suffix)) do
+ Sign.verify_detached(sign, bin, pk)
+ else
+ {:error, :invalid_pk_suffix}
+ end
+ end
+end
diff --git a/shard/lib/manager.ex b/shard/lib/manager.ex
index 57f2371..617378c 100644
--- a/shard/lib/manager.ex
+++ b/shard/lib/manager.ex
@@ -1,5 +1,17 @@
defprotocol Shard.Manifest do
- @doc "Start the corresponding Shard process"
+ @moduledoc"""
+ A shard manifest is a data structure that uniquely defines the identity of the shard.
+
+ The hash of the manifest is the unique identifier of that shard on the network.
+
+ The Manifest protocol is a protocol implemented by the manifest structs for the
+ different shard types. It contains an operation start() that is able to launch the
+ correct process for this shard and connect to other peers that use it.
+ """
+
+ @doc"""
+ Start the corresponding Shard process
+ """
def start(manifest)
end
@@ -261,6 +273,16 @@ defmodule Shard.Manager do
end
@doc"""
+ Returns the pid for a shard if it exists
+ """
+ def find_proc(shard_id) do
+ case :dets.lookup(@shard_db, shard_id) do
+ [{^shard_id, _, pid}] -> pid
+ _ -> nil
+ end
+ end
+
+ @doc"""
Register a process as the handler for shard packets for a given path.
"""
def dispatch_to(shard_id, path, pid) do
diff --git a/shard/lib/net/tcpconn.ex b/shard/lib/net/tcpconn.ex
index 35f7ea5..543341a 100644
--- a/shard/lib/net/tcpconn.ex
+++ b/shard/lib/net/tcpconn.ex
@@ -20,7 +20,7 @@ defmodule SNet.TCPConn do
def handle_cast(:handshake, state) do
socket = state.socket
- {srv_pkey, srv_skey} = Shard.Identity.get_keypair
+ {srv_pkey, srv_skey} = Shard.Keys.get_peer_keypair
{:ok, sess_pkey, sess_skey} = Box.keypair
{:ok, challenge} = Salty.Random.buf 32
diff --git a/shard/mix.exs b/shard/mix.exs
index 7192feb..14d0581 100644
--- a/shard/mix.exs
+++ b/shard/mix.exs
@@ -26,6 +26,7 @@ defmodule Shard.MixProject do
defp deps do
[
{:excoveralls, "~> 0.10", only: :test},
+ {:ex_doc, "~> 0.19", only: :dev, runtime: false},
{:salty, "~> 0.1.3", hex: :libsalty},
]
diff --git a/shard/mix.lock b/shard/mix.lock
index 4f92013..d9fe6a9 100644
--- a/shard/mix.lock
+++ b/shard/mix.lock
@@ -2,15 +2,20 @@
"certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"},
+ "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], [], "hexpm"},
"elixir_make": {:hex, :elixir_make, "0.4.2", "332c649d08c18bc1ecc73b1befc68c647136de4f340b548844efc796405743bf", [:mix], [], "hexpm"},
"ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"},
+ "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
"excoveralls": {:hex, :excoveralls, "0.10.0", "a4508bdd408829f38e7b2519f234b7fd5c83846099cda348efcb5291b081200c", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
+ "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
+ "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
+ "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"},
"plug": {:hex, :plug, "1.3.6", "bcdf94ac0f4bc3b804bdbdbde37ebf598bd7ed2bfa5106ed1ab5984a09b7e75f", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"},
"ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"},