Skip to content

Commit

Permalink
put_aws_sigv4: Drop :aws_signature dependency
Browse files Browse the repository at this point in the history
  • Loading branch information
wojtekmach committed Mar 15, 2024
1 parent decc731 commit 1886a77
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 57 deletions.
4 changes: 1 addition & 3 deletions lib/req.ex
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,7 @@ defmodule Req do
* `:region` - if set, AWS region. Defaults to `"us-east-1"`.
This functionality requires [`:aws_signature`](https://hex.pm/packages/aws_signature) dependency:
{:aws_signature, "~> 0.3.0"}
* `:datetime` - the request datetime, defaults to `DateTime.utc_now(:second)`.
Response body options:
Expand Down
58 changes: 14 additions & 44 deletions lib/req/steps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1425,17 +1425,9 @@ defmodule Req.Steps do
defp hash_init(:sha1), do: hash_init(:sha)
defp hash_init(type), do: :crypto.hash_init(type)

defmacrop aws_signature_loaded? do
Code.ensure_loaded?(:aws_signature)
end

@doc """
Signs request with AWS Signature Version 4.
This step requires [`:aws_signature`](https://hex.pm/packages/aws_signature) dependency:
{:aws_signature, "~> 0.3.0"}
## Request Options
* `:aws_sigv4` - if set, the AWS options to sign request:
Expand Down Expand Up @@ -1480,18 +1472,6 @@ defmodule Req.Steps do
@doc step: :request
def put_aws_sigv4(request) do
if aws_options = request.options[:aws_sigv4] do
unless aws_signature_loaded?() do
Logger.error("""
Could not find aws_signature dependency.
Please add :aws_signature to your dependencies:
{:aws_signature, "~> 0.3.0"}
""")

raise "missing aws_signature dependency"
end

aws_options =
case aws_options do
list when is_list(list) ->
Expand All @@ -1505,8 +1485,12 @@ defmodule Req.Steps do
":aws_sigv4 must be a keywords list or a map, got: #{inspect(other)}"
end

# aws_credentials returns this key so let's ignore it
aws_options = Keyword.drop(aws_options, [:credential_provider])
aws_options =
aws_options
|> Keyword.put_new(:region, "us-east-1")
|> Keyword.put_new(:datetime, DateTime.utc_now())
# aws_credentials returns this key so let's ignore it
|> Keyword.drop([:credential_provider])

Req.Request.validate_options(aws_options, [
:access_key_id,
Expand All @@ -1516,17 +1500,6 @@ defmodule Req.Steps do
:datetime
])

access_key_id = Keyword.fetch!(aws_options, :access_key_id)
secret_access_key = Keyword.fetch!(aws_options, :secret_access_key)
service = Keyword.fetch!(aws_options, :service)
region = Keyword.get(aws_options, :region, "us-east-1")
datetime = Keyword.get(aws_options, :datetime)

now =
(datetime || DateTime.utc_now(:second))
|> DateTime.to_naive()
|> NaiveDateTime.to_erl()

{body, options} =
case request.body do
nil ->
Expand All @@ -1548,17 +1521,14 @@ defmodule Req.Steps do
headers = for {name, values} <- request.headers, value <- values, do: {name, value}

headers =
:aws_signature.sign_v4(
access_key_id,
secret_access_key,
region,
to_string(service),
now,
to_string(request.method),
to_string(request.url),
headers,
body,
options
Req.Utils.aws_sigv4(
aws_options ++
[
method: request.method,
url: to_string(request.url),
headers: headers,
body: body
] ++ options
)

Req.merge(request, headers: headers)
Expand Down
106 changes: 106 additions & 0 deletions lib/req/utils.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
defmodule Req.Utils do
@moduledoc false

defmacrop iodata({:<<>>, _, parts}) do
Enum.map(parts, &to_iodata/1)
end

defp to_iodata(binary) when is_binary(binary) do
binary
end

defp to_iodata(
{:"::", _, [{{:., _, [Kernel, :to_string]}, _, [interpolation]}, {:binary, _, nil}]}
) do
interpolation
end

@doc """
Create AWS Signature v4.
https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
"""
def aws_sigv4(options) do
{access_key_id, options} = Keyword.pop!(options, :access_key_id)
{secret_access_key, options} = Keyword.pop!(options, :secret_access_key)
{region, options} = Keyword.pop!(options, :region)
{service, options} = Keyword.pop!(options, :service)
{datetime, options} = Keyword.pop!(options, :datetime)
{method, options} = Keyword.pop!(options, :method)
{url, options} = Keyword.pop!(options, :url)
{headers, options} = Keyword.pop!(options, :headers)
{body, options} = Keyword.pop!(options, :body)
Keyword.validate!(options, [:body_digest])

datetime = DateTime.truncate(datetime, :second)
datetime_string = DateTime.to_iso8601(datetime, :basic)
date_string = Date.to_iso8601(datetime, :basic)
method = method |> Atom.to_string() |> String.upcase()
url = URI.parse(url)
body_digest = options[:body_digest] || hex(sha256(body))
service = to_string(service)

canonical_headers =
headers ++
[
{"x-amz-content-sha256", body_digest},
{"x-amz-date", datetime_string}
]

signed_headers =
Enum.map_intersperse(
Enum.sort(canonical_headers),
";",
&String.downcase(elem(&1, 0), :ascii)
)

canonical_request =
iodata("""
#{String.upcase(method)}
#{url.path || "/"}
#{url.query || ""}
#{Enum.map_intersperse(canonical_headers, "\n", fn {name, value} -> [name, ":", value] end)}
#{signed_headers}
#{body_digest}\
""")

string_to_sign =
iodata("""
AWS4-HMAC-SHA256
#{datetime_string}
#{date_string}/#{region}/#{service}/aws4_request
#{hex(sha256(canonical_request))}\
""")

signature =
["AWS4", secret_access_key]
|> hmac(date_string)
|> hmac(region)
|> hmac(service)
|> hmac("aws4_request")
|> hmac(string_to_sign)
|> hex()

authorization =
"AWS4-HMAC-SHA256 Credential=#{access_key_id}/#{date_string}/#{region}/#{service}/aws4_request,SignedHeaders=#{signed_headers},Signature=#{signature}"

[
{"authorization", authorization},
{"x-amz-content-sha256", body_digest},
{"x-amz-date", datetime_string}
] ++ headers
end

defp hex(data) do
Base.encode16(data, case: :lower)
end

defp sha256(data) do
:crypto.hash(:sha256, data)
end

defp hmac(key, data) do
:crypto.mac(:hmac, :sha256, key, data)
end
end
5 changes: 2 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ defmodule Req.MixProject do
Plug.Test,
Plug.Conn,
:brotli,
:ezstd,
:aws_signature
:ezstd
]
]
]
Expand Down Expand Up @@ -59,10 +58,10 @@ defmodule Req.MixProject do
{:jason, "~> 1.0"},
{:nimble_ownership, "~> 0.2.0 or ~> 0.3.0"},
{:nimble_csv, "~> 1.0", optional: true},
{:aws_signature, "~> 0.3.2", optional: true},
{:plug, "~> 1.0", [optional: true] ++ plug_opts()},
{:brotli, "~> 0.3.1", optional: true},
{:ezstd, "~> 1.0", optional: true},
{:aws_signature, "~> 0.3.2", only: :test},
{:bypass, "~> 2.1", only: :test},
{:ex_doc, ">= 0.0.0", only: :docs},
{:bandit, "~> 1.0", only: :test}
Expand Down
14 changes: 7 additions & 7 deletions test/req/integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,18 @@ defmodule Req.IntegrationTest do
handle_http_errors: 1
]

@aws_access_key_id System.get_env("REQ_AWS_ACCESS_KEY_ID")
@aws_secret_access_key System.get_env("REQ_AWS_SECRET_ACCESS_KEY")
@aws_bucket System.get_env("REQ_AWS_BUCKET")

@tag :s3
test "s3" do
aws_access_key_id = System.fetch_env!("REQ_AWS_ACCESS_KEY_ID")
aws_secret_access_key = System.fetch_env!("REQ_AWS_SECRET_ACCESS_KEY")
aws_bucket = System.fetch_env!("REQ_AWS_BUCKET")

req =
Req.new(
base_url: "https://#{@aws_bucket}.s3.amazonaws.com",
base_url: "https://#{aws_bucket}.s3.amazonaws.com",
aws_sigv4: [
access_key_id: @aws_access_key_id,
secret_access_key: @aws_secret_access_key,
access_key_id: aws_access_key_id,
secret_access_key: aws_secret_access_key,
service: :s3
]
)
Expand Down
37 changes: 37 additions & 0 deletions test/req/utils_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule Req.UtilsTest do
use ExUnit.Case, async: true

describe "aws_sigv4" do
test "GET" do
options = [
access_key_id: "dummy-access-key-id",
secret_access_key: "dummy-secret-access-key",
region: "dummy-region",
service: "s3",
datetime: ~U[2024-01-01 09:00:00Z],
method: :get,
url: "https://s3",
headers: [{"host", "s3"}],
body: ""
]

signature1 = Req.Utils.aws_sigv4(options)

signature2 =
:aws_signature.sign_v4(
Keyword.fetch!(options, :access_key_id),
Keyword.fetch!(options, :secret_access_key),
Keyword.fetch!(options, :region),
Keyword.fetch!(options, :service),
NaiveDateTime.to_erl(Keyword.fetch!(options, :datetime)),
String.upcase(Atom.to_string(Keyword.fetch!(options, :method))),
Keyword.fetch!(options, :url),
Keyword.fetch!(options, :headers),
Keyword.fetch!(options, :body)
)

assert signature1 ==
Enum.map(signature2, fn {name, value} -> {String.downcase(name), value} end)
end
end
end

0 comments on commit 1886a77

Please sign in to comment.