From 3f922b68f558143a2d664951539fdde893cc5851 Mon Sep 17 00:00:00 2001 From: Michael Guarino Date: Fri, 1 Jun 2018 00:50:31 -0400 Subject: [PATCH 1/5] First Pass Cloudfront Signature algorithm * Allows configuration of the RSA private key by file or env var * Supports a simple expiration policy (although others are possible) --- .formatter.exs | 4 ++ .gitignore | 24 +++++++++++ .tool-versions | 2 + README.md | 22 +++++++++- config/config.exs | 3 ++ config/dev.exs | 1 + config/prod.exs | 1 + config/test.exs | 58 +++++++++++++++++++++++++++ lib/cloudfront_signer.ex | 48 ++++++++++++++++++++++ lib/cloudfront_signer/distribution.ex | 41 +++++++++++++++++++ lib/cloudfront_signer/policy.ex | 32 +++++++++++++++ lib/cloudfront_signer/signature.ex | 22 ++++++++++ mix.exs | 26 ++++++++++++ mix.lock | 15 +++++++ test/cloudfront_signer_test.exs | 16 ++++++++ test/test_helper.exs | 1 + 16 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 .tool-versions 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/cloudfront_signer.ex create mode 100644 lib/cloudfront_signer/distribution.ex create mode 100644 lib/cloudfront_signer/policy.ex create mode 100644 lib/cloudfront_signer/signature.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/cloudfront_signer_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..525446d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec25939 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +cloudfront_signer-*.tar + diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..08f9f74 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 20.1.7 +elixir 1.6.3 \ No newline at end of file diff --git a/README.md b/README.md index e2d67c7..75c9550 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ -# cloudfront-signer-ex \ No newline at end of file +# CloudfrontSigner + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `cloudfront_signer` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:cloudfront_signer, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/cloudfront_signer](https://hexdocs.pm/cloudfront_signer). + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..d88c269 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +use Mix.Config + +import_config "#{Mix.env}.exs" \ No newline at end of file diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..a7d258e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +use Mix.Config \ No newline at end of file diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..a7d258e --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +use Mix.Config \ No newline at end of file diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..5c29424 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,58 @@ +use Mix.Config + +config :cloudfront_signer, CloudfrontSignerTest, + address: "https://somewhere.cloudfront.com", + key_pair_id: "a_key_pair", + private_key: """ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +""" \ No newline at end of file diff --git a/lib/cloudfront_signer.ex b/lib/cloudfront_signer.ex new file mode 100644 index 0000000..dd98338 --- /dev/null +++ b/lib/cloudfront_signer.ex @@ -0,0 +1,48 @@ +defmodule CloudfrontSigner do + @moduledoc """ + Elixir implementation of cloudfront's signed url algorithm. Basic usage is: + + ``` + CloudfrontSigner.Distribution.from_config(:scope, :key) + |> CloudfrontSigner.sign("some/path", [arg: "val"], some_expiry) + ``` + """ + alias CloudfrontSigner.{Distribution, Policy, Signature} + + @doc """ + Signs a url for the given `Distribution.t` struct constructed from the `path` and `query_params` provided. `expiry` + is in seconds. + """ + @spec sign(Distribution.t, binary, list, integer) :: binary + def sign(%Distribution{address: address, private_key: pk, key_pair_id: kpi}, path, query_params \\ [], expiry) do + expiry = Timex.now() |> Timex.shift(seconds: expiry) |> Timex.to_unix() + base_url = URI.merge(address, path) |> to_string() + url = url(base_url, query_params) + signature = signature(url, expiry, pk) + aws_query = signature_params(expiry, signature, kpi) + + signed_url(url, query_params, aws_query) + end + + defp url(base, []), do: base + defp url(base, ""), do: base + defp url(base, query_params), do: base <> "?" <> prepare_query_params(query_params) + + defp signed_url(base, [], aws_query), do: base <> "?" <> aws_query + defp signed_url(base, "", aws_query), do: base <> "?" <> aws_query + defp signed_url(base, _params, aws_query), do: base <> "&" <> aws_query + + defp prepare_query_params(query_params) when is_binary(query_params), do: query_params + defp prepare_query_params(query_params) when is_list(query_params) or is_map(query_params) do + URI.encode_query(query_params) + end + + defp signature_params(expires, signature, key_pair_id) do + "Expires=#{expires}&Signature=#{signature}&Key-Pair-Id=#{key_pair_id}" + end + + def signature(url, expiry, private_key) do + %Policy{resource: url, expiry: expiry} + |> Signature.signature(private_key) + end +end diff --git a/lib/cloudfront_signer/distribution.ex b/lib/cloudfront_signer/distribution.ex new file mode 100644 index 0000000..90fd3bd --- /dev/null +++ b/lib/cloudfront_signer/distribution.ex @@ -0,0 +1,41 @@ +defmodule CloudfrontSigner.Distribution do + @moduledoc """ + Representation of a cloudfront distribution for the purpose of computing a signed url. + We need the address of the distribution and the private key for RSA signature generation + """ + defstruct [:private_key, :address, :key_pair_id] + + @type t :: %__MODULE__{} + + @doc """ + Creates a `Distribution.t` record from the contents of `Application.get_env(app, scope)` + """ + @spec from_config(atom, atom) :: t + def from_config(app, scope) do + Application.get_env(app, scope) + |> Enum.map(&parse_config/1) + |> Enum.filter(& &1) + |> Enum.into(%{}) + |> from_map() + end + + @spec from_map(map) :: t + def from_map(map), do: struct(__MODULE__, map) |> decode_pk() + + defp parse_config({:address, value}), do: {:address, read_value(value)} + defp parse_config({:private_key, value}), do: {:private_key, read_value(value)} + defp parse_config({:key_pair_id, value}), do: {:key_pair_id, read_value(value)} + defp parse_config(_), do: nil + + defp read_value({:system, env_var}), do: System.get_env(env_var) + defp read_value({:file, file_path}), do: File.read!(file_path) + defp read_value(value) when is_binary(value), do: value + + defp decode_pk(%__MODULE__{private_key: pk} = dist) when is_binary(pk) do + case :public_key.pem_decode(pk) do + [pem_entry] -> %{dist | private_key: :public_key.pem_entry_decode(pem_entry)} + _ -> raise ArgumentError, "Invalid PEM for cloudfront private key" + end + end + defp decode_pk(dist), do: dist +end \ No newline at end of file diff --git a/lib/cloudfront_signer/policy.ex b/lib/cloudfront_signer/policy.ex new file mode 100644 index 0000000..bedf88f --- /dev/null +++ b/lib/cloudfront_signer/policy.ex @@ -0,0 +1,32 @@ +defmodule CloudfrontSigner.Policy do + @moduledoc """ + Defines a cloudfront signature policy, and a string coercion method for it + """ + defstruct [:resource, :expiry] + + @type t :: %__MODULE__{} + + @doc """ + Converts a `Policy.t` to a json-encoded binary + """ + @spec to_string(t) :: binary + def to_string(%__MODULE__{resource: resource, expiry: expiry}) do + aws_policy(resource, expiry) + |> Poison.encode!() + end + + defp aws_policy(resource, expiry) do + %{ + Statement: [ + %{ + Resource: resource, + Condition: %{ + DateLessThan: %{ + "AWS:EpochTime": expiry + } + } + } + ] + } + end +end \ No newline at end of file diff --git a/lib/cloudfront_signer/signature.ex b/lib/cloudfront_signer/signature.ex new file mode 100644 index 0000000..8c0c2d0 --- /dev/null +++ b/lib/cloudfront_signer/signature.ex @@ -0,0 +1,22 @@ +defmodule CloudfrontSigner.Signature do + @moduledoc """ + Manages policy signing + """ + alias CloudfrontSigner.Policy + + @whitespace ~r/\s+/ + + @doc """ + Converts a `Policy.t` struct to a cloudfront signature for the given private key + """ + @spec signature(Policy.t, tuple) :: binary + def signature(%Policy{} = policy, private_key) do + :crypto.hash(:sha, Policy.to_string(policy)) + |> :public_key.encrypt_private(private_key) + |> String.replace(@whitespace, "") + |> Base.encode64() + |> String.replace("+", "-") + |> String.replace("=", "_") + |> String.replace("/", "~") + end +end \ No newline at end of file diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..1ac55e8 --- /dev/null +++ b/mix.exs @@ -0,0 +1,26 @@ +defmodule CloudfrontSigner.MixProject do + use Mix.Project + + def project do + [ + app: :cloudfront_signer, + version: "0.1.0", + elixir: "~> 1.6", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:poison, "~> 3.1"}, + {:timex, "~> 3.1"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..544ecf2 --- /dev/null +++ b/mix.lock @@ -0,0 +1,15 @@ +%{ + "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, + "hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [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.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, + "timex": {:hex, :timex, "3.3.0", "e0695aa0ddb37d460d93a2db34d332c2c95a40c27edf22fbfea22eb8910a9c8d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, + "tzdata": {:hex, :tzdata, "0.5.16", "13424d3afc76c68ff607f2df966c0ab4f3258859bbe3c979c9ed1606135e7352", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, +} diff --git a/test/cloudfront_signer_test.exs b/test/cloudfront_signer_test.exs new file mode 100644 index 0000000..a5958d5 --- /dev/null +++ b/test/cloudfront_signer_test.exs @@ -0,0 +1,16 @@ +defmodule CloudfrontSignerTest do + use ExUnit.Case + doctest CloudfrontSigner + + describe "#sign/3" do + test "it will return something" do + distribution = CloudfrontSigner.Distribution.from_config(:cloudfront_signer, CloudfrontSignerTest) + signed_url = CloudfrontSigner.sign(distribution, "/bucket/key", [arg: "val"], 60 * 1000) + + assert signed_url =~ "Signature" + assert signed_url =~ "Expires" + assert signed_url =~ "Key-Pair-Id" + assert signed_url =~ "/bucket/key?arg=val" + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From 56311a96d938210c858ea4c852e7f9234c83b99f Mon Sep 17 00:00:00 2001 From: Michael Guarino Date: Fri, 1 Jun 2018 13:37:52 -0400 Subject: [PATCH 2/5] update README --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 75c9550..a62b866 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # CloudfrontSigner -**TODO: Add description** +Elixir implementation of Cloudfront's url signature algorithm. Supports expiration policies and +runtime configurable distributions. ## Installation @@ -15,7 +16,17 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at [https://hexdocs.pm/cloudfront_signer](https://hexdocs.pm/cloudfront_signer). +Configure a distribution with: +```elixir +config :my_app, :my_distribution, + address: "https://some.cloudfront.domain", + private_key: {:system, "ENV_VAR"}, # or {:file, "/path/to/key"} + private_key_id: {:system, "OTHER_ENV_VAR"} +``` + +Then simply do: +```elixir +CloudfrontSigner.Distribution.from_config(:my_app, :my_distribution) +|> CloudfrontSigner.sign(path, [arg: "value"], expiry_in_seconds) +``` From 088fe16f6b61388f58e906cc003f8147d33befaf Mon Sep 17 00:00:00 2001 From: Michael Guarino Date: Fri, 1 Jun 2018 15:35:29 -0400 Subject: [PATCH 3/5] improve signature performance --- lib/cloudfront_signer/policy.ex | 39 +++++++++++++++--------------- lib/cloudfront_signer/signature.ex | 14 ++++++++--- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/lib/cloudfront_signer/policy.ex b/lib/cloudfront_signer/policy.ex index bedf88f..494a6b8 100644 --- a/lib/cloudfront_signer/policy.ex +++ b/lib/cloudfront_signer/policy.ex @@ -6,27 +6,28 @@ defmodule CloudfrontSigner.Policy do @type t :: %__MODULE__{} - @doc """ - Converts a `Policy.t` to a json-encoded binary - """ - @spec to_string(t) :: binary - def to_string(%__MODULE__{resource: resource, expiry: expiry}) do - aws_policy(resource, expiry) - |> Poison.encode!() - end + defimpl String.Chars, for: CloudfrontSigner.Policy do + @doc """ + json encodes a policy + """ + def to_string(%{resource: resource, expiry: expiry}) do + aws_policy(resource, expiry) + |> Poison.encode!() + end - defp aws_policy(resource, expiry) do - %{ - Statement: [ - %{ - Resource: resource, - Condition: %{ - DateLessThan: %{ - "AWS:EpochTime": expiry + defp aws_policy(resource, expiry) do + %{ + Statement: [ + %{ + Resource: resource, + Condition: %{ + DateLessThan: %{ + "AWS:EpochTime": expiry + } } } - } - ] - } + ] + } + end end end \ No newline at end of file diff --git a/lib/cloudfront_signer/signature.ex b/lib/cloudfront_signer/signature.ex index 8c0c2d0..3cdcf4d 100644 --- a/lib/cloudfront_signer/signature.ex +++ b/lib/cloudfront_signer/signature.ex @@ -11,12 +11,18 @@ defmodule CloudfrontSigner.Signature do """ @spec signature(Policy.t, tuple) :: binary def signature(%Policy{} = policy, private_key) do - :crypto.hash(:sha, Policy.to_string(policy)) + :crypto.hash(:sha, to_string(policy)) |> :public_key.encrypt_private(private_key) |> String.replace(@whitespace, "") |> Base.encode64() - |> String.replace("+", "-") - |> String.replace("=", "_") - |> String.replace("/", "~") + |> String.to_charlist() + |> Enum.map(&replace/1) + |> to_string() end + + @compile {:inline, replace: 1} + defp replace(?+), do: ?- + defp replace(?=), do: ?_ + defp replace(?/), do: ?~ + defp replace(c), do: c end \ No newline at end of file From 1d616920033f6f586e8bf89923aebe4bb6312562 Mon Sep 17 00:00:00 2001 From: Michael Guarino Date: Sat, 2 Jun 2018 17:27:30 -0400 Subject: [PATCH 4/5] fix signature generation --- config/test.exs | 2 +- generate-signature.sh | 2 ++ lib/cloudfront_signer.ex | 4 ++-- lib/cloudfront_signer/distribution.ex | 8 ++++--- lib/cloudfront_signer/signature.ex | 7 ++---- test/cloudfront_signer/signature_test.exs | 26 +++++++++++++++++++++++ 6 files changed, 38 insertions(+), 11 deletions(-) create mode 100755 generate-signature.sh create mode 100644 test/cloudfront_signer/signature_test.exs diff --git a/config/test.exs b/config/test.exs index 5c29424..e6c93cc 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,7 +1,7 @@ use Mix.Config config :cloudfront_signer, CloudfrontSignerTest, - address: "https://somewhere.cloudfront.com", + domain: "https://somewhere.cloudfront.com", key_pair_id: "a_key_pair", private_key: """ -----BEGIN RSA PRIVATE KEY----- diff --git a/generate-signature.sh b/generate-signature.sh new file mode 100755 index 0000000..8843b38 --- /dev/null +++ b/generate-signature.sh @@ -0,0 +1,2 @@ +#/bin/sh +pbpaste | tr -d "\n" | openssl sha1 -sign private_key.pem | openssl base64 | tr -- '+=/' '-_~' \ No newline at end of file diff --git a/lib/cloudfront_signer.ex b/lib/cloudfront_signer.ex index dd98338..c84dc1e 100644 --- a/lib/cloudfront_signer.ex +++ b/lib/cloudfront_signer.ex @@ -14,9 +14,9 @@ defmodule CloudfrontSigner do is in seconds. """ @spec sign(Distribution.t, binary, list, integer) :: binary - def sign(%Distribution{address: address, private_key: pk, key_pair_id: kpi}, path, query_params \\ [], expiry) do + def sign(%Distribution{domain: domain, private_key: pk, key_pair_id: kpi}, path, query_params \\ [], expiry) do expiry = Timex.now() |> Timex.shift(seconds: expiry) |> Timex.to_unix() - base_url = URI.merge(address, path) |> to_string() + base_url = URI.merge(domain, path) |> to_string() url = url(base_url, query_params) signature = signature(url, expiry, pk) aws_query = signature_params(expiry, signature, kpi) diff --git a/lib/cloudfront_signer/distribution.ex b/lib/cloudfront_signer/distribution.ex index 90fd3bd..5c724a5 100644 --- a/lib/cloudfront_signer/distribution.ex +++ b/lib/cloudfront_signer/distribution.ex @@ -3,7 +3,7 @@ defmodule CloudfrontSigner.Distribution do Representation of a cloudfront distribution for the purpose of computing a signed url. We need the address of the distribution and the private key for RSA signature generation """ - defstruct [:private_key, :address, :key_pair_id] + defstruct [:private_key, :domain, :key_pair_id] @type t :: %__MODULE__{} @@ -22,7 +22,7 @@ defmodule CloudfrontSigner.Distribution do @spec from_map(map) :: t def from_map(map), do: struct(__MODULE__, map) |> decode_pk() - defp parse_config({:address, value}), do: {:address, read_value(value)} + defp parse_config({:domain, value}), do: {:domain, read_value(value)} defp parse_config({:private_key, value}), do: {:private_key, read_value(value)} defp parse_config({:key_pair_id, value}), do: {:key_pair_id, read_value(value)} defp parse_config(_), do: nil @@ -32,7 +32,9 @@ defmodule CloudfrontSigner.Distribution do defp read_value(value) when is_binary(value), do: value defp decode_pk(%__MODULE__{private_key: pk} = dist) when is_binary(pk) do - case :public_key.pem_decode(pk) do + String.trim(pk) + |> :public_key.pem_decode() + |> case do [pem_entry] -> %{dist | private_key: :public_key.pem_entry_decode(pem_entry)} _ -> raise ArgumentError, "Invalid PEM for cloudfront private key" end diff --git a/lib/cloudfront_signer/signature.ex b/lib/cloudfront_signer/signature.ex index 3cdcf4d..d8a880f 100644 --- a/lib/cloudfront_signer/signature.ex +++ b/lib/cloudfront_signer/signature.ex @@ -4,16 +4,13 @@ defmodule CloudfrontSigner.Signature do """ alias CloudfrontSigner.Policy - @whitespace ~r/\s+/ - @doc """ Converts a `Policy.t` struct to a cloudfront signature for the given private key """ @spec signature(Policy.t, tuple) :: binary def signature(%Policy{} = policy, private_key) do - :crypto.hash(:sha, to_string(policy)) - |> :public_key.encrypt_private(private_key) - |> String.replace(@whitespace, "") + to_string(policy) + |> :public_key.sign(:sha, private_key) |> Base.encode64() |> String.to_charlist() |> Enum.map(&replace/1) diff --git a/test/cloudfront_signer/signature_test.exs b/test/cloudfront_signer/signature_test.exs new file mode 100644 index 0000000..1fa8b5b --- /dev/null +++ b/test/cloudfront_signer/signature_test.exs @@ -0,0 +1,26 @@ +defmodule CloudfrontSigner.SignatureTest do + use ExUnit.Case + + @correct_signature """ +ToshDrR-FhIhiStqrp8kAEyQ3YGsz-P5Nh9~~lu0m5l4V-qg3K9Pp~pjYCIYR4yC +OmsN2D1JwBSNYh0hv3l0y7Z2-94hvxx----T6hewE9~kwklOgBfpIcik0AywRDmj +1mmMvhN~5xhEOcnIsErhWiZAm9EpfuHGieH850buSS3rFuNT0DF8Drxmigw7FQgK +XqwddmOaUDlGgjfTvW~n6RSvcRrKBb9Ej~Bjb7~wA8w0p8oKfSyCTGdHfEmNrTW8 +kkSi5VnIsHs1~PowwtBv2C2emPFASxKIN2j6Tf5U6Y8x4yMkweee1sr7c39No0Nk +qfabb52SQgIKgXIqIqYwsfsUYHafg9LBMpdVlJjOfIXjaLm1G9ePDQOca1ZMXuVE +LxEBY42IvHiOEeyg4fuw7tVH5DQP3vGT7FoT5NykPsZvxMusYDpfboo63SuKYxVe +rj4x6LlTdVVIrzSS-cmTSZ0y2h4Ok5MLzgW-2sD-e5vRro8H8xTwsfyp8V~wa0Em +ypd2C6WAqa19hvBqHpQx~OaClPh8KNKEZyw-kfJJAuDM~CAy4vX5J3V0XGDcgI-R +DZiZUiPXoTqRlssx-G66UMK0axZwtworvTQyJisMSbGjnRaEA9vgwKo5EEqYGaxc +HQTrKY0PC2Wcm0qUFL5QbqRqU1RL5K3DW5bPNSVdWJo_ +""" |> String.replace(~r/\s+/, "") + + describe "#signature/2" do + test "It will compute the correct signature for a policy" do + policy = %CloudfrontSigner.Policy{resource: "https://cloudfront.domain.com/some/path", expiry: 1527884313} + distribution = CloudfrontSigner.Distribution.from_config(:cloudfront_signer, CloudfrontSignerTest) + + assert CloudfrontSigner.Signature.signature(policy, distribution.private_key) == @correct_signature + end + end +end \ No newline at end of file From 726fbb9f0a44353cfb8d6b188325689076514234 Mon Sep 17 00:00:00 2001 From: Michael Guarino Date: Sat, 2 Jun 2018 22:29:26 -0400 Subject: [PATCH 5/5] add an agent to store distributino state (and avoid multiple pem decodes when runtime configured) --- lib/cloudfront_signer/application.ex | 12 +++++++++++ .../distribution_registry.ex | 20 +++++++++++++++++++ mix.exs | 3 ++- .../distribution_registry_test.exs | 11 ++++++++++ test/cloudfront_signer/signature_test.exs | 2 +- test/cloudfront_signer_test.exs | 2 +- 6 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 lib/cloudfront_signer/application.ex create mode 100644 lib/cloudfront_signer/distribution_registry.ex create mode 100644 test/cloudfront_signer/distribution_registry_test.exs diff --git a/lib/cloudfront_signer/application.ex b/lib/cloudfront_signer/application.ex new file mode 100644 index 0000000..fbc432d --- /dev/null +++ b/lib/cloudfront_signer/application.ex @@ -0,0 +1,12 @@ +defmodule CloudfrontSigner.Application do + use Application + import Supervisor.Spec + + def start(_type, _args) do + children = [ + worker(CloudfrontSigner.DistributionRegistry, []) + ] + opts = [strategy: :one_for_one, name: CloudfrontSigner.Application.Supervisor] + Supervisor.start_link(children, opts) + end +end \ No newline at end of file diff --git a/lib/cloudfront_signer/distribution_registry.ex b/lib/cloudfront_signer/distribution_registry.ex new file mode 100644 index 0000000..ba2a819 --- /dev/null +++ b/lib/cloudfront_signer/distribution_registry.ex @@ -0,0 +1,20 @@ +defmodule CloudfrontSigner.DistributionRegistry do + @moduledoc """ + Agent to store and fetch cloudfront distributions, to avoid expensive runtime pem decodes + """ + use Agent + alias CloudfrontSigner.Distribution + + def start_link() do + Agent.start_link(fn -> %{} end, name: __MODULE__) + end + + def get_distribution(scope, key) do + Agent.get_and_update(__MODULE__, &Map.get_and_update(&1, {scope, key}, fn + nil -> + dist = Distribution.from_config(scope, key) + {dist, dist} + dist -> {dist, dist} + end)) + end +end \ No newline at end of file diff --git a/mix.exs b/mix.exs index 1ac55e8..22f3880 100644 --- a/mix.exs +++ b/mix.exs @@ -13,7 +13,8 @@ defmodule CloudfrontSigner.MixProject do def application do [ - extra_applications: [:logger] + extra_applications: [:logger], + mod: {CloudfrontSigner.Application, []} ] end diff --git a/test/cloudfront_signer/distribution_registry_test.exs b/test/cloudfront_signer/distribution_registry_test.exs new file mode 100644 index 0000000..7ccd3a5 --- /dev/null +++ b/test/cloudfront_signer/distribution_registry_test.exs @@ -0,0 +1,11 @@ +defmodule CloudfrontSigner.DistributionRegistryTest do + use ExUnit.Case, async: true + + describe "#get_distribution/2" do + test "It will get a registry and save it internally" do + distribution = CloudfrontSigner.DistributionRegistry.get_distribution(:cloudfront_signer, CloudfrontSignerTest) + + assert distribution.domain == "https://somewhere.cloudfront.com" + end + end +end \ No newline at end of file diff --git a/test/cloudfront_signer/signature_test.exs b/test/cloudfront_signer/signature_test.exs index 1fa8b5b..5f1561d 100644 --- a/test/cloudfront_signer/signature_test.exs +++ b/test/cloudfront_signer/signature_test.exs @@ -1,5 +1,5 @@ defmodule CloudfrontSigner.SignatureTest do - use ExUnit.Case + use ExUnit.Case, async: true @correct_signature """ ToshDrR-FhIhiStqrp8kAEyQ3YGsz-P5Nh9~~lu0m5l4V-qg3K9Pp~pjYCIYR4yC diff --git a/test/cloudfront_signer_test.exs b/test/cloudfront_signer_test.exs index a5958d5..ea858f8 100644 --- a/test/cloudfront_signer_test.exs +++ b/test/cloudfront_signer_test.exs @@ -1,5 +1,5 @@ defmodule CloudfrontSignerTest do - use ExUnit.Case + use ExUnit.Case, async: true doctest CloudfrontSigner describe "#sign/3" do