├── rebar.lock ├── .gitignore ├── rebar.config ├── src ├── aws_signature.app.src ├── aws_sigv4_utils.erl ├── aws_sigv4_internal.hrl ├── aws_sigv4a_credentials.erl ├── aws_sigv4a.erl ├── aws_sigv4_internal.erl ├── aws_signature_utils.erl └── aws_signature.erl ├── README.md ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── CHANGELOG.md ├── LICENSE └── test ├── aws_sigv4a_tests.erl └── aws_sigv4_internal_tests.erl /rebar.lock: -------------------------------------------------------------------------------- 1 | []. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rebar3 2 | _* 3 | .eunit 4 | *.o 5 | *.beam 6 | *.plt 7 | *.swp 8 | *.swo 9 | .erlang.cookie 10 | ebin 11 | log 12 | erl_crash.dump 13 | .rebar 14 | logs 15 | _build 16 | .idea 17 | *.iml 18 | rebar3.crashdump 19 | *~ 20 | doc/ 21 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {erl_opts, [debug_info]}. 2 | {deps, []}. 3 | {project_plugins, [rebar3_hex, rebar3_ex_doc]}. 4 | 5 | {ex_doc, [ 6 | {extras, [<<"CHANGELOG.md">>, <<"LICENSE">>]}, 7 | {main, <<"aws_signature">>}, 8 | {source_url, <<"https://github.com/aws-beam/aws_signature">>} 9 | ]}. 10 | 11 | {hex, [ 12 | {doc, #{provider => ex_doc}} 13 | ]}. 14 | -------------------------------------------------------------------------------- /src/aws_signature.app.src: -------------------------------------------------------------------------------- 1 | {application, aws_signature, 2 | [{description, "Request signature implementation for authorizing AWS API calls"}, 3 | {vsn, "git"}, 4 | {registered, []}, 5 | {applications, 6 | [kernel, 7 | stdlib 8 | ]}, 9 | {env,[]}, 10 | {modules, []}, 11 | {licenses, ["Apache-2.0"]}, 12 | {links, [{"GitHub", "https://github.com/aws-beam/aws_signature"}]}, 13 | {doc, "doc"} 14 | ]}. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Signature 2 | 3 | [Documentation](https://hexdocs.pm/aws_signature) 4 | 5 | Request signature implementation for authorizing AWS API calls. 6 | 7 | ## Installation 8 | 9 | The package is available on [Hex](https://hex.pm/packages/aws_signature). 10 | To install, just add it to your dependencies in `mix.exs`: 11 | 12 | ```elixir 13 | defp deps() do 14 | [ 15 | {:aws_signature, "~> 0.3.2"} 16 | ] 17 | end 18 | ``` 19 | 20 | or `rebar.config`: 21 | 22 | ```erlang 23 | {deps, [{aws_signature, "~> 0.3.2"}]}. 24 | ``` 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | platform: [ubuntu-latest] 13 | otp-version: [26, 27, 28] 14 | runs-on: ${{ matrix.platform }} 15 | container: 16 | image: erlang:${{ matrix.otp-version }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Cache Hex packages 21 | uses: actions/cache@v3 22 | with: 23 | path: ~/.cache/rebar3/hex/hexpm/packages 24 | key: ${{ runner.os }}-hex-${{ hashFiles(format('{0}{1}', github.workspace, '/rebar.lock')) }} 25 | restore-keys: | 26 | ${{ runner.os }}-hex- 27 | - name: Compile 28 | run: rebar3 compile 29 | - name: Run EUnit Tests 30 | run: rebar3 eunit 31 | - name: Run Dialyzer 32 | run: rebar3 dialyzer 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | container: 13 | image: erlang:27 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: Take ownership of the checkout directory (Git CVE-2022-24765) 21 | run: chown --recursive --reference=/ . 22 | 23 | - name: Allow for file ownership conflicts with Docker and GitHub Actions 24 | run: git config --global --add safe.directory '*' 25 | 26 | - name: Compile 27 | run: rebar3 compile 28 | 29 | - name: Run EUnit Tests 30 | run: rebar3 eunit 31 | 32 | - name: Run Dialyzer 33 | run: rebar3 dialyzer 34 | 35 | - name: Generate docs 36 | run: rebar3 ex_doc 37 | 38 | - uses: ncipollo/release-action@v1 39 | with: 40 | generateReleaseNotes: true 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Publish to hex.pm 44 | uses: erlangpack/github-action@v4 45 | env: 46 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 47 | -------------------------------------------------------------------------------- /src/aws_sigv4_utils.erl: -------------------------------------------------------------------------------- 1 | -module(aws_sigv4_utils). 2 | 3 | -export([ binaries_join/2 4 | , format_time_long/1 5 | , format_time_short/1 6 | , sha256/1 7 | ]). 8 | 9 | -spec binaries_join(binary(), [binary()]) -> binary(). 10 | binaries_join(Separator, Binaries) -> 11 | iolist_to_binary(lists:join(Separator, Binaries)). 12 | 13 | -spec format_time_long(calendar:datetime()) -> binary(). 14 | format_time_long({{YY, MM, DD}, {H, M, S}}) -> 15 | format_timestamp(format_date(YY, MM, DD), H, M, S). 16 | 17 | -spec format_time_short(calendar:datetime()) -> binary(). 18 | format_time_short({{YY, MM, DD}, _}) -> 19 | format_date(YY, MM, DD). 20 | 21 | -spec format_date(non_neg_integer(), non_neg_integer(), non_neg_integer()) -> binary(). 22 | format_date(YY, MM, DD) -> 23 | YB = integer_to_binary(YY), 24 | MB = maybe_pad(MM), 25 | DB = maybe_pad(DD), 26 | <>. 27 | 28 | -spec format_timestamp(binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) -> binary(). 29 | format_timestamp(Date, H, M, S) -> 30 | HB = maybe_pad(H), 31 | MB = maybe_pad(M), 32 | SB = maybe_pad(S), 33 | <>. 34 | 35 | -spec maybe_pad(non_neg_integer()) -> binary(). 36 | maybe_pad(X) when X < 10 -> 37 | <<"0", (integer_to_binary(X))/binary>>; 38 | maybe_pad(X) -> 39 | integer_to_binary(X). 40 | 41 | -spec sha256(binary()) -> binary(). 42 | sha256(Binary) -> 43 | crypto:hash(sha256, Binary). 44 | -------------------------------------------------------------------------------- /src/aws_sigv4_internal.hrl: -------------------------------------------------------------------------------- 1 | -ifndef(_AWS_SIGV4_INTERNAL_HRL_). 2 | -define(_AWS_SIGV4_INTERNAL_HRL_, true). 3 | 4 | -record(request, 5 | { method :: binary() 6 | , url :: binary() 7 | , headers :: aws_sigv4_internal:headers() 8 | , body :: binary() 9 | , host :: binary() 10 | }). 11 | 12 | %% https://github.com/aws/smithy-go/blob/main/aws-http-auth/credentials/credentials.go 13 | 14 | -record(credentials, 15 | { access_key_id :: binary() 16 | , secret_access_key :: binary() 17 | , session_token :: binary() 18 | }). 19 | 20 | %% https://github.com/aws/smithy-go/blob/main/aws-http-auth/v4/v4.go 21 | 22 | -record(v4_signer_options, 23 | { is_signed :: fun((binary()) -> boolean()) | undefined 24 | , disable_implicit_payload_hashing = false :: boolean() 25 | , disable_double_path_escape = false :: boolean() 26 | , add_payload_hash_header = false :: boolean() 27 | }). 28 | 29 | -define(UNSIGNED_PAYLOAD, <<"UNSIGNED-PAYLOAD">>). 30 | 31 | %% https://github.com/aws/smithy-go/blob/main/aws-http-auth/internal/v4/signer.go 32 | 33 | -record(internal_signer, 34 | { request :: aws_sigv4_internal:request() 35 | , payload_hash :: binary() % raw binary, NOT hex-encoded 36 | , time :: calendar:datetime() 37 | , credentials :: aws_sigv4_internal:credentials() 38 | , options :: aws_sigv4_internal:v4_signer_options() 39 | , algorithm :: binary() 40 | , credential_scope :: binary() 41 | , sign_string :: aws_sigv4_internal:sign_string() 42 | }). 43 | 44 | %% https://github.com/aws/smithy-go/blob/main/aws-http-auth/sigv4a/sigv4a.go 45 | 46 | -record(v4a_sign_request_input, 47 | { request :: aws_sigv4_internal:request() 48 | , payload_hash :: binary() % raw binary, NOT hex-encoded 49 | , credentials :: aws_sigv4_internal:credentials() 50 | , service :: binary() 51 | , regions :: [binary()] 52 | , time :: calendar:datetime() | undefined 53 | }). 54 | 55 | -endif. % _AWS_SIGV4_INTERNAL_HRL_ 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## [v0.3.2](https://github.com/aws-beam/aws_signature/tree/v0.3.2) (2024-02-16) 8 | 9 | ### Added 10 | 11 | - Add support for signing AWS event stream messages ([#23](https://github.com/aws-beam/aws_signature/pull/23)) 12 | - Add body_digest option to sign_v4/10 ([#25](https://github.com/aws-beam/aws_signature/pull/25)) 13 | 14 | ## [v0.3.1](https://github.com/aws-beam/aws_signature/tree/v0.3.1) (2022-04-27) 15 | 16 | ### Fixed 17 | 18 | - Signature for URLs with explicit port component ([#18](https://github.com/aws-beam/aws_signature/pull/18)) 19 | 20 | ## [v0.3.0](https://github.com/aws-beam/aws_signature/tree/v0.3.0) (2022-04-12) 21 | 22 | This release changes the default behaviour of `sign_v4_query_params`. Instead of 23 | setting the body digest to "UNSIGNED-PAYLOAD" it now computes the digest of an 24 | empty string. To retain the current behaviour you need to pass an option: 25 | 26 | ```diff 27 | -sign_v4_query_params(AccessKeyID, SecretAccessKey, Region, Service, DateTime, Method, URL, []). 28 | +sign_v4_query_params(AccessKeyID, SecretAccessKey, Region, Service, DateTime, Method, URL, [{body_digest, <<"UNSIGNED-PAYLOAD">>}]). 29 | ``` 30 | 31 | ### Added 32 | 33 | - Support for body signing in presigned requests ([#15](https://github.com/aws-beam/aws_signature/pull/15)) 34 | 35 | ### Changed 36 | 37 | - Default body digest in `sign_v4_query_params` signature ([#15](https://github.com/aws-beam/aws_signature/pull/15)) 38 | 39 | ## [v0.2.0](https://github.com/aws-beam/aws_signature/tree/v0.2.0) (2021-09-27) 40 | 41 | ### Changed 42 | 43 | - Changed `sign_v4_query_params` signatures to also accept HTTP method ([#11](https://github.com/aws-beam/aws_signature/pull/11)) 44 | 45 | ## [v0.1.1](https://github.com/aws-beam/aws_signature/tree/v0.1.1) (2021-08-28) 46 | 47 | ### Added 48 | 49 | - Support for query string signature ([#7](https://github.com/aws-beam/aws_signature/pull/7)) 50 | 51 | ## [v0.1.0](https://github.com/aws-beam/aws_signature/tree/v0.1.0) (2021-08-17) 52 | 53 | Initial release 54 | -------------------------------------------------------------------------------- /src/aws_sigv4a_credentials.erl: -------------------------------------------------------------------------------- 1 | %% Based on: 2 | %% https://github.com/aws/smithy-go/blob/main/aws-http-auth/sigv4a/credentials.go 3 | -module(aws_sigv4a_credentials). 4 | 5 | -export([ derive/1 6 | ]). 7 | 8 | -ifdef(TEST). 9 | -export([ derive_private_key/1 10 | ]). 11 | -endif. 12 | 13 | -include("aws_sigv4_internal.hrl"). 14 | 15 | -spec derive(aws_sigv4_internal:credentials()) -> {ok, binary()} | {error, any()}. 16 | derive(Credentials) -> 17 | #credentials{access_key_id = AKID} = Credentials, 18 | case persistent_term:get(?MODULE, false) of 19 | {AKID, PrivateKey} -> {ok, PrivateKey}; 20 | _ -> 21 | case derive_private_key(Credentials) of 22 | {ok, PrivateKey} = Result -> 23 | persistent_term:put(?MODULE, {AKID, PrivateKey}), 24 | Result; 25 | {error, _Reason} = Error -> Error 26 | end 27 | end. 28 | 29 | %% See "Deriving a signing key for SigV4a" subsection of: 30 | %% https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#derive-signing-key 31 | -spec derive_private_key(aws_sigv4_internal:credentials()) -> {ok, binary()} | {error, any()}. 32 | derive_private_key(Credentials) -> 33 | NMinus2 = p256_n() - 2, 34 | #credentials{access_key_id = AKID, secret_access_key = SK} = Credentials, 35 | InputKey = << <<"AWS4A">>/binary, SK/binary>>, 36 | derive_private_key(_Counter = 1, NMinus2, AKID, InputKey). 37 | 38 | -spec derive_private_key(byte(), non_neg_integer(), binary(), binary()) -> {ok, binary()} | {error, any()}. 39 | derive_private_key(Counter, NMinus2, AKID, InputKey) -> 40 | case Counter < 255 of 41 | true -> 42 | Context = <>, 43 | Key = derive_hmac_key(InputKey, Context), 44 | KeyNrBits = byte_size(Key) * 8, 45 | KeyNrBits = 256, % assert 46 | <> = Key, 47 | case C > NMinus2 of 48 | true -> derive_private_key(Counter + 1, NMinus2, AKID, InputKey); 49 | false -> {ok, <<(C + 1):KeyNrBits/big>>} 50 | end; 51 | false -> {error, exhausted_single_byte_external_counter} 52 | end. 53 | 54 | -spec p256_n() -> non_neg_integer(). 55 | p256_n() -> 56 | 115792089210356248762697446949407573529996955224135760342422259061068512044369. 57 | 58 | -spec p256_bitsize() -> non_neg_integer(). 59 | p256_bitsize() -> 60 | 256. 61 | 62 | -spec derive_hmac_key(binary(), binary()) -> binary(). 63 | derive_hmac_key(Key, Context) -> 64 | BitLen = p256_bitsize(), 65 | N = ((BitLen + 7) div 8) div sha256_size(), 66 | Label = <<"AWS4-ECDSA-P256-SHA256">>, 67 | FixedInput = <