Skip to content

Commit

Permalink
Merge pull request #1 from Frameio/mguarino/cloudfront_singer
Browse files Browse the repository at this point in the history
First Pass Cloudfront Signature algorithm
  • Loading branch information
michaeljguarino authored Jun 4, 2018
2 parents 78b7241 + 726fbb9 commit 15f6ca1
Show file tree
Hide file tree
Showing 21 changed files with 404 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
24 changes: 24 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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

2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
erlang 20.1.7
elixir 1.6.3
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
# cloudfront-signer-ex
# CloudfrontSigner

Elixir implementation of Cloudfront's url signature algorithm. Supports expiration policies and
runtime configurable distributions.

## 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
```

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)
```
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use Mix.Config

import_config "#{Mix.env}.exs"
1 change: 1 addition & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use Mix.Config
1 change: 1 addition & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use Mix.Config
58 changes: 58 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use Mix.Config

config :cloudfront_signer, CloudfrontSignerTest,
domain: "https://somewhere.cloudfront.com",
key_pair_id: "a_key_pair",
private_key: """
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----
"""
2 changes: 2 additions & 0 deletions generate-signature.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#/bin/sh
pbpaste | tr -d "\n" | openssl sha1 -sign private_key.pem | openssl base64 | tr -- '+=/' '-_~'
48 changes: 48 additions & 0 deletions lib/cloudfront_signer.ex
Original file line number Diff line number Diff line change
@@ -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{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(domain, 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
12 changes: 12 additions & 0 deletions lib/cloudfront_signer/application.ex
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions lib/cloudfront_signer/distribution.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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, :domain, :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({: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

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
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
end
defp decode_pk(dist), do: dist
end
20 changes: 20 additions & 0 deletions lib/cloudfront_signer/distribution_registry.ex
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions lib/cloudfront_signer/policy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule CloudfrontSigner.Policy do
@moduledoc """
Defines a cloudfront signature policy, and a string coercion method for it
"""
defstruct [:resource, :expiry]

@type t :: %__MODULE__{}

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
}
}
}
]
}
end
end
end
25 changes: 25 additions & 0 deletions lib/cloudfront_signer/signature.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule CloudfrontSigner.Signature do
@moduledoc """
Manages policy signing
"""
alias CloudfrontSigner.Policy

@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
to_string(policy)
|> :public_key.sign(:sha, private_key)
|> Base.encode64()
|> 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
27 changes: 27 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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],
mod: {CloudfrontSigner.Application, []}
]
end

defp deps do
[
{:poison, "~> 3.1"},
{:timex, "~> 3.1"}
]
end
end
15 changes: 15 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}
11 changes: 11 additions & 0 deletions test/cloudfront_signer/distribution_registry_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 15f6ca1

Please sign in to comment.