From 24a5171cc3e9d15dc328d0b0cfd472e117388dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Caro?= Date: Sun, 6 Mar 2016 19:25:04 +0100 Subject: [PATCH] WIP road to 1.0 --- config/config.exs | 3 + config/dev.exs | 5 ++ config/prod.exs | 1 + config/test.exs | 5 ++ lib/cipher.ex | 173 ++++++++++++++++++++++++++---------------- lib/cipher/digest.ex | 20 +++++ lib/cipher/helpers.ex | 47 ++++++++++++ mix.exs | 2 +- test/cipher_test.exs | 63 ++++++++------- 9 files changed, 224 insertions(+), 95 deletions(-) create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/test.exs create mode 100644 lib/cipher/digest.ex create mode 100644 lib/cipher/helpers.ex diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..c9c59bb --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +use Mix.Config + +import_config "#{Mix.env}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..9ea831a --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,5 @@ +use Mix.Config + +config :cipher, keyphrase: "testiekeyphraseforcipher", + ivphrase: "testieivphraseforcipher", + magic_token: "magictoken" diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..9ea831a --- /dev/null +++ b/config/test.exs @@ -0,0 +1,5 @@ +use Mix.Config + +config :cipher, keyphrase: "testiekeyphraseforcipher", + ivphrase: "testieivphraseforcipher", + magic_token: "magictoken" diff --git a/lib/cipher.ex b/lib/cipher.ex index 0893bf5..2b12931 100644 --- a/lib/cipher.ex +++ b/lib/cipher.ex @@ -1,75 +1,56 @@ +require Cipher.Helpers, as: H # the cool way defmodule Cipher do @moduledoc """ Helpers to encrypt and decrypt data. """ + # handy to have them around + @k H.env(:keyphrase) |> Cipher.Digest.generate_key + @i H.env(:ivphrase) |> Cipher.Digest.generate_iv @doc """ - Returns encrypted string containing given `data` string, using given `key` - and `iv`. - Suitable `key` and `iv` can be generated with `generate_key/1` - and `generate_iv/1`. + Returns encrypted string containing given `data` string """ - def encrypt(data, key, iv) do - encrypted = :crypto.block_encrypt :aes_cbc128, key, iv, pad(data) + def encrypt(data) do + encrypted = :crypto.block_encrypt :aes_cbc128, @k, @i, pad(data) encrypted |> Base.encode64 |> URI.encode_www_form end @doc """ - Returns decrypted string contained in given `crypted` string, using given - `key` and `iv`. - Suitable `key` and `iv` can be generated with `generate_key/1` - and `generate_iv/1`. + Returns decrypted string contained in given `crypted` string """ - def decrypt(crypted, key, iv) do + def decrypt(crypted) do {:ok, decoded} = crypted |> URI.decode_www_form |> Base.decode64 - :crypto.block_decrypt(:aes_cbc128, key, iv, decoded) |> depad - end - - @doc "Generates a suitable key for encryption based on given `phrase`" - def generate_key(phrase) do - :crypto.hash(:sha, phrase) |> hexdigest |> String.slice(0,16) - end - - @doc "Generates a suitable iv for encryption based on given `phrase`" - def generate_iv(phrase), do: phrase |> String.slice(0,16) - - @doc "Gets an usable string from a binary crypto hash" - def hexdigest(binary) do - :lists.flatten(for b <- :erlang.binary_to_list(binary), - do: :io_lib.format("~2.16.0B", [b])) - |> :string.to_lower - |> List.to_string + :crypto.block_decrypt(:aes_cbc128, @k, @i, decoded) |> depad end @doc """ - Pad given string until its length is divisible by 16. - It uses PKCS#7 padding. + Returns the JSON parsed data of the given crypted string, with labeled tuples """ - def pad(str, block_size \\ 16) do - len = byte_size(str) - utfs = len - String.length(str) # UTF chars are 2byte, ljust counts only 1 - pad_len = block_size - rem(len, block_size) - utfs - String.ljust(str, len + pad_len, pad_len) # PKCS#7 padding + def parse(crypted) do + try do + {:ok, crypted |> decrypt |> Poison.decode!} + rescue + reason -> {:error, reason} + end end - @doc "Remove PKCS#7 padding from given string." - def depad(str) do - <> = String.last str - String.rstrip str, last - end + @doc """ + Returns the JSON converted and encrypted version of given data + """ + def cipher(data), do: data |> Poison.encode! |> encrypt @doc """ Gets signature for given `base` and appends as a param to `url`. Returns `url` with appended param. """ - def sign(url, base, key, iv) do + def sign(url, base) do nexus = if String.contains?(url, "?"), do: "&", else: "?" - signature = :crypto.hash(:md5, base) |> hexdigest + signature = :crypto.hash(:md5, base) |> Cipher.Digest.hexdigest {_, _, micros} = :os.timestamp pepper = micros |> Integer.to_string |> String.rjust(8) # 8 characters long - crypted = signature <> pepper |> encrypt(key, iv) + crypted = signature <> pepper |> encrypt url <> nexus <> "signature=" <> crypted end @@ -77,32 +58,43 @@ defmodule Cipher do An URL is signed by getting a hash from it, ciphering that hash, and appending it as the last query parameter. """ - def sign_url(url, key, iv), do: sign(url, url, key, iv) + def sign_url(url), do: sign(url, url) @doc """ - Pops the signature param, which must be the last one. - Returns the remaining url and the popped signature. + Decrypts `ciphered`, and compare with an MD5 hash got from base. + Returns false if decryption failed, or if comparison failed. + Whatever parsed otherwise. """ - def pop_signature(url) do - case String.split(url, "signature=") do - [dirty_url, popped] -> - clean_url = String.slice dirty_url, 0..-2 # remove nexus - {clean_url, popped} - _ -> {url, ""} + def validate_signature(ciphered, base, rest) do + case parse(ciphered) do + {:ok, parsed} -> validate_parsed_signature(parsed, base, rest) + any -> any end end - @doc """ - Decrypts `ciphered`, and compare with an MD5 hash got from base. - Returns false if decryption failed, or if comparison failed. True otherwise. - """ - def validate_signature(ciphered, base, key, iv) do - try do - plain = decrypt(ciphered, key, iv) - signature = :crypto.hash(:md5, base) |> hexdigest - signature == String.slice(plain, 0..-9) # removing pepper from parsed - rescue - _ -> false + defp validate_parsed_signature(parsed, base, rest) do + ignored = parsed["data"] |> Map.get("ignore", []) + case validate_ignored(ignored, rest) do + :ok -> validate_base(parsed, base) + :error -> false + end + end + + defp validate_ignored(_, []), do: :ok + defp validate_ignored(ignored, [r | rest]) do + case r in ignored do + true -> validate_ignored(ignored, rest) + false -> :error + end + end + + defp validate_base(parsed, base) do + signature = :crypto.hash(:md5, base) + |> Cipher.Digest.hexdigest + read_signature = String.slice(parsed["data"]["md5"], 0..-9) # removing pepper from parsed + case signature == read_signature do + true -> parsed["data"] + false -> false end end @@ -111,9 +103,58 @@ defmodule Cipher do get an MD5 hash of the remains, decrypt popped value, and compare with the MD5 hash """ - def validate_signed_url(url, key, iv) do - {clean_url, popped} = pop_signature(url) - validate_signature(popped, clean_url, key, iv) + def validate_signed_url(url) do + {clean_url, popped, rest} = pop_signature(url) + validate_magic_token(popped, clean_url, rest) + end + + @doc """ + Pop the last parameter from the URL, decrypt it, + get an MD5 of the body, and compare with the decrypted value + """ + def validate_signed_body(url, body) do + {_, popped, rest} = pop_signature(url) + validate_magic_token(popped, body, rest) + end + + defp validate_magic_token(popped, base, rest) do + case popped == H.env(:magic_token) do + true -> true + false -> validate_signature(popped, base, rest) + end + end + + # Pad given string until its length is divisible by 16. + # It uses PKCS#7 padding. + # + defp pad(str, block_size \\ 16) do + len = byte_size(str) + utfs = len - String.length(str) # UTF chars are 2byte, ljust counts only 1 + pad_len = block_size - rem(len, block_size) - utfs + String.ljust(str, len + pad_len, pad_len) # PKCS#7 padding + end + + # Remove PKCS#7 padding from given string. + defp depad(str) do + <> = String.last str + String.rstrip str, last + end + + # Pops the signature param, which must be the last one. + # Returns the remaining url and the popped signature. + # + defp pop_signature(url) do + case String.split(url, "signature=") do + [dirty_url, popped] -> pop_signature(dirty_url, popped) + _ -> {url, "", ""} + end + end + defp pop_signature(dirty_url, popped) do + clean_url = String.slice(dirty_url, 0..-2) + case String.split(popped, "&") do + [signature, rest] -> {clean_url, signature, rest} + _ -> {clean_url, popped, ""} + end end end diff --git a/lib/cipher/digest.ex b/lib/cipher/digest.ex new file mode 100644 index 0000000..8c8877d --- /dev/null +++ b/lib/cipher/digest.ex @@ -0,0 +1,20 @@ +defmodule Cipher.Digest do + + @moduledoc """ + Some digesting helpers + """ + + @doc "Generates a suitable key for encryption based on given `phrase`" + def generate_key(phrase), do: :crypto.hash(:sha, phrase) |> hexdigest |> String.slice(0,16) + + @doc "Generates a suitable iv for encryption based on given `phrase`" + def generate_iv(phrase), do: phrase |> String.slice(0,16) + + @doc "Gets an usable string from a binary crypto hash" + def hexdigest(binary) do + :lists.flatten(for b <- :erlang.binary_to_list(binary), do: :io_lib.format("~2.16.0B", [b])) + |> :string.to_lower + |> List.to_string + end + +end diff --git a/lib/cipher/helpers.ex b/lib/cipher/helpers.ex new file mode 100644 index 0000000..df0d243 --- /dev/null +++ b/lib/cipher/helpers.ex @@ -0,0 +1,47 @@ +defmodule Cipher.Helpers do + + @moduledoc """ + require Cipher.Helpers, as: H # the cool way + """ + @doc """ + Convenience to get environment bits. Avoid all that repetitive + `Application.get_env( :myapp, :blah, :blah)` noise. + """ + def env(key, default \\ nil), do: env(Mix.Project.get!.project[:app], key, default) + def env(app, key, default), do: Application.get_env(app, key, default) + + @doc """ + Spit to output any passed variable, with location information. + """ + defmacro spit(obj \\ "", inspect_opts \\ []) do + quote do + %{file: file, line: line} = __ENV__ + name = Process.info(self)[:registered_name] + chain = [ :bright, :red, "\n\n#{file}:#{line}", + :normal, "\n #{inspect self}", :green," #{name}"] + + msg = inspect(unquote(obj),unquote(inspect_opts)) + if String.length(msg) > 2, do: chain = chain ++ [:red, "\n\n#{msg}"] + + # chain = chain ++ [:yellow, "\n\n#{inspect Process.info(self)}"] + + (chain ++ ["\n\n", :reset]) |> IO.ANSI.format(true) |> IO.puts + + unquote(obj) + end + end + + @doc """ + Print to stdout a _TODO_ message, with location information. + """ + defmacro todo(msg \\ "") do + quote do + %{file: file, line: line} = __ENV__ + [ :yellow, "\nTODO: #{file}:#{line} #{unquote(msg)}\n", :reset] + |> IO.ANSI.format(true) + |> IO.puts + :todo + end + end + +end diff --git a/mix.exs b/mix.exs index affd4d4..0c14166 100644 --- a/mix.exs +++ b/mix.exs @@ -24,7 +24,7 @@ defmodule Cipher.Mixfile do end defp deps do - [{:poison, "~> 2.0", only: :test}] + [{:poison, "~> 2.0"}] end end diff --git a/test/cipher_test.exs b/test/cipher_test.exs index 20f9c1d..2d00be6 100644 --- a/test/cipher_test.exs +++ b/test/cipher_test.exs @@ -1,25 +1,21 @@ +require Cipher.Helpers, as: H # the cool way + defmodule CipherTest do use ExUnit.Case, async: true alias Cipher, as: C test "the whole encrypt/decrypt stack" do s = Poison.encode! %{"hola": " qué tal クソ"} - assert s == s |> C.encrypt(k,i) |> C.decrypt(k,i) + assert s == s |> C.encrypt |> C.decrypt end test "parse ciphered hash" do - h = %{"hola": " qué tal クソ"} + h = %{"hola" => " qué tal クソ"} s = Poison.encode! h - res = s |> C.encrypt(k,i) |> C.parse(k,i) - assert res.valid - assert res.data == h - - res = C.parse 'very invalid', k, i) - refute res.valid - - res = C.parse(C.encrypt(s,k,i)) <> "slightly invalid", k, i) - refute res.valid + assert {:ok, ^h} = s |> C.encrypt |> C.parse + assert {:error, _} = 'very invalid' |> C.parse + assert {:error, _} = (C.encrypt(s) <> "slightly invalid") |> C.parse end test "get ciphered hash" do @@ -30,34 +26,45 @@ defmodule CipherTest do test "validate_signed_url" do # ok with regular urls - assert "/bla/bla" |> C.sign_url(k,i) |> C.validate_signed_url(k,i) - assert "/bla/bla?sdfasdf=sdfgadf&dsfasdf=addfga" |> C.sign_url(k,i) |> C.validate_signed_url(k,i) + url = "/bla/bla" + H.spit("#{url}" |> C.sign_url |> C.validate_signed_url) + assert {:ok, _} = "#{url}" |> C.sign_url |> C.validate_signed_url + assert "#{url}?sdfasdf=sdfgadf&dsfasdf=addfga" |> C.sign_url |> C.validate_signed_url # not signed and wrongly signed fails - refute "/bla/bla" |> C.validate_signed_url(k,i) - refute "/bla/bla?signature=badhash" |> C.validate_signed_url(k,i) - refute "/bla/bla?asdkjh=sdfklh&signature=badhash" |> C.validate_signed_url(k,i) + refute "#{url}" |> C.validate_signed_url + refute "#{url}?signature=badhash" |> C.validate_signed_url + refute "#{url}?asdkjh=sdfklh&signature=badhash" |> C.validate_signed_url end test "it works ignoring some too" do - s = "/bla/bla" |> C.sign_url(k, i, ignored: ["source"]) - assert C.validate_signed_url(s, k, i) - assert C.validate_signed_url(s <> "&source=crappysource", k, i) - refute C.validate_signed_url(s <> "&other=crappysource", k, i) + url = "/bla/bla" + s = "#{url}" |> C.sign_url(ignored: ["source"]) + assert C.validate_signed_url(s) + assert C.validate_signed_url(s <> "&source=crappysource") + refute C.validate_signed_url(s <> "&other=crappysource") end - test "Magic Token works" do - assert "/bla/bla?a=123&signature=#{C.magic_token}" |> C.validate_signed_url(k,i) - refute "/bla/bla?a=123&signature=#{C.magic_token}X" |> C.validate_signed_url(k,i) + test "Magic Token works with url" do + url = "/bla/bla" + assert "#{url}?a=123&signature=#{H.env(:magic_token)}" |> C.validate_signed_url + refute "#{url}?a=123&signature=#{H.env(:magic_token)}X" |> C.validate_signed_url end - test "validate_signed_body" do + test "Magic Token works with body" do + url = "/bla/bla" body = Poison.encode! %{"hola": " qué tal クソ"} - + assert "#{url}?a=123&signature=#{H.env(:magic_token)}" |> C.validate_signed_body(body) + refute "#{url}?a=123&signature=#{H.env(:magic_token)}X" |> C.validate_signed_body(body) end - # handy to have them around - defp k, do: "testiekeyphraseforcipher"|> C.generate_key - defp i, do: "testieivphraseforcipher" |> C.generate_iv + test "validate_signed_body" do + url = "/bla/bla" + body = Poison.encode! %{"hola": " qué tal クソ"} + refute "#{url}" |> C.validate_signed_body(body) + refute "#{url}?signature=badhash" |> C.validate_signed_body(body) + refute "#{url}?asdkjh=sdfklh&signature=badhash" |> C.validate_signed_body(body) + assert "#{url}" |> C.sign_url_from_body(body) |> C.validate_signed_body(body) + end end