Skip to content

Commit

Permalink
Merge pull request #3 from cliftonmcintosh/cmcintosh/remove-applicati…
Browse files Browse the repository at this point in the history
…on-order-keys

Remove application file, insure key order in signature
  • Loading branch information
cliftonmcintosh authored Feb 11, 2025
2 parents dcc0792 + 028ef17 commit 4cd5363
Show file tree
Hide file tree
Showing 20 changed files with 426 additions and 136 deletions.
4 changes: 0 additions & 4 deletions .dialyzer.ignore-warnings
Original file line number Diff line number Diff line change
@@ -1,4 +0,0 @@
lib/cloudfront_signer.ex:16: Invalid type specification for function 'Elixir.CloudfrontSigner':sign/4.
The success typing is 'Elixir.CloudfrontSigner':sign(#{'__struct__':='Elixir.CloudfrontSigner.Distribution', 'domain':=binary() | #{'__struct__':='Elixir.URI', 'authority':='Elixir.URI':authority(), 'fragment':='nil' | binary(), 'host':='nil' | binary(), 'path':='nil' | binary(), 'port':='nil' | char(), 'query':='nil' | binary(), 'scheme':='nil' | binary(), 'userinfo':='nil' | binary()}, 'key_pair_id':=_, 'private_key':={{'RSAPrivateKey',_,_,_,_,_,_,_,_,_,_},{'RSASSA-PSS-params',_,_,_,_}} | {'ECPrivateKey',_,_,_,_,_} | {'DSAPrivateKey',_,_,_,_,_,_} | {'RSAPrivateKey',_,_,_,_,_,_,_,_,_,_}, _=>_},binary() | #{'__struct__':='Elixir.URI', 'authority':='Elixir.URI':authority(), 'fragment':='nil' | binary(), 'host':='nil' | binary(), 'path':='nil' | binary(), 'port':='nil' | char(), 'query':='nil' | binary(), 'scheme':='nil' | binary(), 'userinfo':='nil' | binary()},integer() | #{'__struct__':='Elixir.Timex.Duration', 'megaseconds':=integer(), 'microseconds':=integer(), 'seconds':=integer()},binary() | maybe_improper_list() | map()) -> nonempty_binary()
But the spec is 'Elixir.CloudfrontSigner':sign('Elixir.CloudfrontSigner.Distribution':t(),binary() | [any()] | map(),[any()],integer()) -> binary()
They do not overlap in the 3rd and 4th arguments
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export PRIVATE_KEY="<openssl genpkey -algorithm RSA>"
export TESTING_PRIVATE_KEY="$(cat test/support/test_private_key.pem)"
4 changes: 2 additions & 2 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
plugins: [Styler]
]
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
name: CI

env:
TESTING_PRIVATE_KEY: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7EWYjTFGlz8yf
ZUfpqBA9wylQ8Xj1mOX6Zh+7wUsa5EdNkqz1tCnnj27BrhqECdwASVkXYZoKexFk
VSiflUvevH2NHtW7zofgp5AZClka+ollqvwktAnsCBxZIKmapIHEHA/efoCFm3Df
GyOyExsR4H6QnHudX6MfKep0CNSu01w4mi8IzSx9QAcQSCysbwdhvU74gNTr8FGG
7++Cd/A4RNdYDGTUR78221AuokQvMCZWlvbNKxQQCPN0TN8GFPOd9NXELoP+g4WE
srwDrhJV4nPRqXUUnk2aRNQF6VrFLk5mQ9HcOGzTkkTzPLeyLUQJcsyhDD9KoFCb
MtHYqyfXAgMBAAECggEATnJ2bZkEqE8jFTjo1lB3Nx9PhTUuL3gGAWKwLhFaCJXw
XNSEmakK5aNdo/T6A6MK0kfwB4ETkw+W9IdNuNZn8akD0Zk7sj1i98/s+sM/KLQl
yC/S+yVQ/91K3gl3dnVEQdQux3QvO1g7FiSQbI3cjTuid8xXfmBrzJbMTgqh/gnG
uCFcTMM5K1a+3lzSUwhKu/HGU05bKxSBHJBQLwRgEh4ko/KQbQSrDlkEZoYV8Ql6
rP+/OpA9pSCaJJsN6D+XRWQq3w0n2EpidtLWBu56ZwXS4XiSoy70QLl9bxRfw6Sf
jh721q4YWnNA0qkuBYjCJAGOVYPeFcg9s7jICrzJOQKBgQDpWmxl2HFyRkX1DITt
wSxuWgAJm8aYlaT8Gda+q5MkNG2DNUw8P7nFtAFbbV0BaPRxw7BiqQoo0wRy4juz
3xnCpNGxOUzsT8cjIHBBOY1Qcf9K26AqwsK7F950XSGJvUwMK3mHemojif774unr
1SqV9H6UXA3IBSfgWmN/g1Ja6wKBgQDNOQj/X0p8euIEeq3OUCmD2voMPii7u3KA
N8t9GNZxIn6KsSUSdh9B9j45NGqLOV7Cg4I1hJuAGHRaOCro96qc7rW6Y3EgQLKK
c+8MRWL7nBFYC35Uk+KZ19jvfm/gYaqB6ut60DJa+wR+25nyNLY6DIlDdx2GfFwd
6QWvbjNTxQKBgC0OZourpQv5gZx1FF2LxPZxrjwstcXUbwy2OH6MRlbhQJvq+JNR
gp8nyHNMxH53M2ub6zzBgtKrG9rKz9hFJYYtqMddVvrx1HN12fbeE+kKec6MZXqc
LFlPnIY/TTB5OmvHISoFeuTtyiv8fkoo2JYnpSEkPQz34eEx0rBWPNqNAoGAL6Ah
12nc1mCKHTH/Ldbqm3/w8XBncZHBs8G/BowCEAVKSpNroAornNoxfIMOirrIo+lz
CUp579M9BUbtplz7iinXoKa9NRfulzTqb/WWT//bAozAR45+UueFn6a+/dqMgFPt
S/YPCZAhbfM2mb/j3jrUjASTpIcttX9DY3/2h2UCgYEAqf00ZmFeu0F9LXBqH3IC
0SZAUKtC5tqIQTNmaGr2xlHWVxyYXEBKLD5LitiuXCagWJP/klWzlTACvVhhXSYw
3hMBoueWtnscA1iOzCO9kMjJ7+IMPTUTQobxm9y8D9VaZh+F/63t2HI1JO9lvlod
BlBzCoN89VjyDCI6uN2iXOI=
-----END PRIVATE KEY-----
on:
pull_request:
branches:
Expand Down
43 changes: 42 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

## [Unreleased]

## 1.0.0 - 2025-02-10

### ⚠️ Breaking Changes

- Remove Application module - users must now add `CloudfrontSigner.DistributionRegistry` to their own supervision tree

#### Example

```elixir
# In your application.ex
def start(_type, _args) do
children = [
# ... other children ...
CloudfrontSigner.DistributionRegistry
]

opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
end
```

### 🚀 Features

- Enforce key order in AWS Policy using Jason.OrderedObject
- Add support for Elixir version 1.15
- Add Styler for consistent code formatting

### 🚜 Refactor

- Remove unused Poison dependency
- Fix test expectations in CloudfrontSignerTest
- Remove test for non-existent module
- Replace Timex with DateTime

### 📚 Documentation

- Update README with guidance for installing via hex
- Add directions for adding registry to application supervision tree
- Improve function docs and typespecs
- Improve test documentation and formatting

## 0.2.0 - 2025-01-28

### 🚀 Features

- Swap Poison for Jason.
- Swap Poison for Jason

### 🐛 Bug Fixes

Expand Down
46 changes: 34 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,66 @@
# CloudfrontSigner

Elixir implementation of Cloudfront's url signature algorithm. Supports expiration policies and
runtime configurable distributions. Fork of https://github.com/Frameio/cloudfront-signer
runtime configurable distributions.

The main benefits that this library provides are:

- Runtime configurable distributions
- Caching of PEM decodes

## Installation

The patched package can be installed
by adding `cloudfront_signer` to your list of dependencies in `mix.exs` as a git based dependency:
Add `cloudfront_signer` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:cloudfront_signer,
git: "https://github.com/podium/cloudfront-signer.git",
ref: "73b53cf1364d92708f43ca60dc7150a61cfa5191"}
{:cloudfront_signer, "~> 1.0.0"}
]
end
```

Consult the [mix documentation for git based dependencies](https://hexdocs.pm/mix/1.16.0/Mix.Tasks.Deps.html) for valid syntax options.
## Configuring a Distribution

Configure a distribution with:

```elixir
config :my_app, :my_distribution,
domain: "https://some.cloudfront.domain",
private_key: {:system, "ENV_VAR"}, # or {:file, "/path/to/key"}
key_pair_id: {:system, "OTHER_ENV_VAR"}
private_key: System.get_env("PRIVATE_KEY"), # or {:file, "/path/to/key"}
key_pair_id: System.get_env("KEY_PAIR_ID")
```

Then simply do:
## Signing a URL without Caching PEM Decodes

Caching PEM decodes is a wise choice, but if you don't want to cache them, you can do the following:

```elixir
CloudfrontSigner.Distribution.from_config(:my_app, :my_distribution)
|> CloudfrontSigner.sign(path, [arg: "value"], expiry_in_seconds)
```

If you want to cache pem decodes (which is a wise choice), a registry of decoded distributions is available. Simply do:
## Caching PEM Decodes

If you want to cache PEM decodes, you can use the distribution registry.
Add `CloudfrontSigner.DistributionRegistry` to your application's supervision tree:

```elixir
# In your application.ex
def start(_type, _args) do
children = [
# ... other children ...
CloudfrontSigner.DistributionRegistry
]

opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
end
```

Then use it like:

```elixir
CloudfrontSigner.DistributionRegistry.get_distribution(:my_app, :my_distribution)
|> CloudfrontSigner.sign(path, [arg: "value], expiry)
|> CloudfrontSigner.sign(path, [arg: "value"], expiry)
```
24 changes: 18 additions & 6 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import Config

config :cloudfront_signer, CloudfrontSigner.DistributionRegistryTest,
domain: "https://test.cloudfront.net",
private_key: System.get_env("TESTING_PRIVATE_KEY"),
key_pair_id: "a_key_pair"

config :cloudfront_signer, CloudfrontSignerTest,
domain: "https://somewhere.cloudfront.com",
key_pair_id: "a_key_pair",
domain: "https://test.cloudfront.net",
private_key:
System.get_env("PRIVATE_KEY") ||
System.get_env("TESTING_PRIVATE_KEY") ||
raise("""
environment variable PRIVATE_KEY is missing.
You can generate one by calling: openssl genpkey -algorithm RSA
""")
environment variable TESTING_PRIVATE_KEY is missing.
You can use the test/support/test_private_key.pem file for your tests.
See the .env.sample file for more information.
You can also generate one by calling: openssl genpkey -algorithm RSA
"""),
key_pair_id: "a_key_pair"

config :cloudfront_signer,
domain: "https://test.cloudfront.net",
private_key: System.get_env("TESTING_PRIVATE_KEY"),
key_pair_id: "a_key_pair"
22 changes: 12 additions & 10 deletions lib/cloudfront_signer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ defmodule CloudfrontSigner do
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 | map, list, integer) :: binary
def sign(
%Distribution{domain: domain, private_key: pk, key_pair_id: kpi},
path,
expiry,
query_params \\ []
) 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)
@spec sign(Distribution.t(), binary() | list() | map(), integer(), list()) :: binary()
def sign(%Distribution{domain: domain, private_key: pk, key_pair_id: kpi}, path, expiry, query_params \\ []) do
expiry =
DateTime.utc_now()
|> DateTime.add(expiry, :second)
|> DateTime.to_unix()

url =
domain
|> URI.merge(path)
|> to_string()
|> url(query_params)

{signature, encoded_policy} =
Policy.generate_signature_and_policy(%Policy{resource: url, expiry: expiry}, pk)
Expand Down
16 changes: 0 additions & 16 deletions lib/cloudfront_signer/application.ex

This file was deleted.

21 changes: 15 additions & 6 deletions lib/cloudfront_signer/distribution.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,25 @@ defmodule CloudfrontSigner.Distribution do
@doc """
Creates a `Distribution.t` record from the contents of `Application.get_env(app, scope)`
"""
@spec from_config(atom, atom) :: t
@spec from_config(atom(), atom()) :: t()
def from_config(app, scope) do
Application.get_env(app, scope)
app
|> Application.get_env(scope)
|> Enum.map(&parse_config/1)
|> Enum.filter(& &1)
|> Enum.into(%{})
|> Map.new()
|> from_map()
end

@spec from_map(map) :: t
def from_map(map), do: struct(__MODULE__, map) |> decode_pk()
@doc """
Creates a `Distribution` struct from a map of attributes.
If the map contains a `:private_key` that is a binary PEM string, it will be decoded
into its internal representation. Other attributes (`:domain` and `:key_pair_id`) are
copied as-is into the struct.
"""
@spec from_map(map()) :: t()
def from_map(map), do: __MODULE__ |> struct(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)}
Expand All @@ -32,7 +40,8 @@ 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
String.trim(pk)
pk
|> String.trim()
|> :public_key.pem_decode()
|> case do
[pem_entry] -> %{dist | private_key: :public_key.pem_entry_decode(pem_entry)}
Expand Down
5 changes: 5 additions & 0 deletions lib/cloudfront_signer/distribution_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ defmodule CloudfrontSigner.DistributionRegistry do
Agent to store and fetch cloudfront distributions, to avoid expensive runtime pem decodes
"""
use Agent

alias CloudfrontSigner.Distribution

def start_link(_opts) do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end

@doc """
Gets a distribution from the registry. If not found, creates it from config and caches it.
"""
@spec get_distribution(atom(), atom()) :: Distribution.t()
def get_distribution(scope, key) do
Agent.get_and_update(
__MODULE__,
Expand Down
Loading

0 comments on commit 4cd5363

Please sign in to comment.