Skip to content

Commit 81caaec

Browse files
authored
Merge pull request #31 from mikpe/add-aws-sigv4a-support
Add aws sigv4a support
2 parents 814b1b0 + d4dcf6f commit 81caaec

8 files changed

+1432
-0
lines changed

src/aws_signature.erl

+39
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,51 @@
22
-module(aws_signature).
33

44
-export([sign_v4/9, sign_v4/10, sign_v4_event/7, sign_v4_query_params/7, sign_v4_query_params/8]).
5+
-export([sign_v4a/10]).
56

67
-type header() :: {binary(), binary()}.
78
-type headers() :: [header()].
89
-type query_param() :: {binary(), binary()}.
910
-type query_params() :: [query_param()].
1011

12+
%% @doc Implements the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html">Asymmetric Signature Version 4 (SigV4a)</a> algorithm.
13+
%%
14+
%% This function takes AWS client credentials and request details,
15+
%% based on which it computes the signature and returns headers
16+
%% extended with the authorization entries.
17+
%%
18+
%% `URL' must be valid, with all components properly escaped.
19+
%% For example, "https://example.com/path%20to" is valid, whereas
20+
%% "https://example.com/path to" is not.
21+
%%
22+
%% It is essential that the provided request details are final
23+
%% and the returned headers are used to make the request. All
24+
%% custom headers need to be assembled before the signature is
25+
%% calculated.
26+
%%
27+
%% The following options are supported:
28+
%%
29+
%% <dl>
30+
%% <dt>`add_payload_hash_header'</dt>
31+
%% <dd>
32+
%% When `true' adds the `X-Amz-Content-Sha256' header to signed requests.
33+
%% Amazon S3 is an example of a service that requires this setting.
34+
%% Defaults to `false'.
35+
%% </dd>
36+
%% <dt>`disable_implicit_payload_hashing'</dt>
37+
%% <dd>
38+
%% When `true' use the "UNSIGNED-PAYLOAD" sentinel instead of computing
39+
%% SHA256 digest of the payload. Defaults to `false'.
40+
%% </dd>
41+
%% </dl>
42+
-spec sign_v4a(binary(), binary(), binary(), [binary()], binary(),
43+
binary(), binary(), headers(), binary(), map())
44+
-> {ok, headers()} | {error, any()}.
45+
sign_v4a(AccessKeyID, SecretAccessKey, SessionToken, Regions,
46+
Service, Method, URL, Headers, Body, Options) ->
47+
aws_sigv4a:sign_request(AccessKeyID, SecretAccessKey, SessionToken, Regions,
48+
Service, Method, URL, Headers, Body, Options).
49+
1150
%% @doc Same as {@link sign_v4/10} with no options.
1251
sign_v4(AccessKeyID, SecretAccessKey, Region, Service, DateTime, Method, URL, Headers, Body) ->
1352
sign_v4(AccessKeyID, SecretAccessKey, Region, Service, DateTime, Method, URL, Headers, Body, []).

src/aws_sigv4_internal.erl

+261
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
%% Based on:
2+
%% https://github.com/aws/smithy-go/blob/main/aws-http-auth/internal/v4/signer.go
3+
-module(aws_sigv4_internal).
4+
5+
-export([ do/1
6+
, resolve_time/1
7+
]).
8+
9+
%% exported for tests
10+
-export([ build_canonical_request/1
11+
, default_is_signed/1
12+
, resolve_payload_hash/1
13+
, set_required_headers/1
14+
]).
15+
16+
-include("aws_sigv4_internal.hrl").
17+
18+
-type credentials() :: #credentials{}.
19+
-type internal_signer() :: #internal_signer{}.
20+
-type request() :: #request{}.
21+
-type v4_signer_options() :: #v4_signer_options{}.
22+
23+
-export_type([ credentials/0
24+
, headers/0
25+
, internal_signer/0
26+
, request/0
27+
, v4_signer_options/0
28+
]).
29+
30+
-spec do(internal_signer()) -> {ok, headers()} | {error, any()}.
31+
do(Signer) ->
32+
Signer1 = init(Signer),
33+
Signer2 = set_required_headers(Signer1),
34+
{CanonicalRequest, SignedHeaders} = build_canonical_request(Signer2),
35+
StringToSign = build_string_to_sign(Signer2, CanonicalRequest),
36+
case sign_string(Signer2, StringToSign) of
37+
{ok, Signature} -> {ok, set_authorization_header(Signer2, SignedHeaders, Signature)};
38+
{error, _Reason} = Error -> Error
39+
end.
40+
41+
-spec set_authorization_header(internal_signer(), binary(), binary()) -> headers().
42+
set_authorization_header(Signer, SignedHeaders, Signature) ->
43+
headers_put(<<"Authorization">>,
44+
build_authorization_header(Signer, SignedHeaders, Signature),
45+
Signer#internal_signer.request#request.headers).
46+
47+
-spec init(internal_signer()) -> internal_signer().
48+
init(Signer) ->
49+
resolve_payload_hash(init_is_signed(Signer)).
50+
51+
-spec init_is_signed(internal_signer()) -> internal_signer().
52+
init_is_signed(Signer) ->
53+
Options = Signer#internal_signer.options,
54+
case Options#v4_signer_options.is_signed of
55+
undefined ->
56+
Options1 = Options#v4_signer_options{is_signed = fun default_is_signed/1},
57+
Signer#internal_signer{options = Options1};
58+
IsSigned when is_function(IsSigned, 1) -> Signer
59+
end.
60+
61+
-spec resolve_payload_hash(internal_signer()) -> internal_signer().
62+
resolve_payload_hash(Signer) ->
63+
case Signer#internal_signer.payload_hash of
64+
Binary when byte_size(Binary) > 0 -> Signer;
65+
_ ->
66+
PayloadHash =
67+
case Signer#internal_signer.options#v4_signer_options.disable_implicit_payload_hashing of
68+
true -> ?UNSIGNED_PAYLOAD;
69+
false -> aws_sigv4_utils:sha256(Signer#internal_signer.request#request.body)
70+
end,
71+
Signer#internal_signer{payload_hash = PayloadHash}
72+
end.
73+
74+
-spec set_required_headers(internal_signer()) -> internal_signer().
75+
set_required_headers(Signer) ->
76+
Request0 = Signer#internal_signer.request,
77+
Headers0 = Request0#request.headers,
78+
Funs =
79+
[ fun set_host_header/2
80+
, fun set_date_header/2
81+
, fun set_security_token_header/2
82+
, fun set_content_sha_header/2
83+
],
84+
Headers = lists:foldl(fun(F, Hs) -> F(Signer, Hs) end, Headers0, Funs),
85+
Request = Request0#request{headers = Headers},
86+
Signer#internal_signer{request = Request}.
87+
88+
-spec set_host_header(internal_signer(), headers()) -> headers().
89+
set_host_header(#internal_signer{request = #request{host = Host}}, Headers) ->
90+
headers_put(<<"Host">>, Host, Headers).
91+
92+
-spec set_date_header(internal_signer(), headers()) -> headers().
93+
set_date_header(#internal_signer{time = Time}, Headers) ->
94+
headers_put(<<"X-Amz-Date">>, aws_sigv4_utils:format_time_long(Time), Headers).
95+
96+
-spec set_security_token_header(internal_signer(), headers()) -> headers().
97+
set_security_token_header(#internal_signer{credentials = Credentials}, Headers) ->
98+
case Credentials#credentials.session_token of
99+
SessionToken when byte_size(SessionToken) > 0 ->
100+
headers_put(<<"X-Amz-Security-Token">>, SessionToken, Headers);
101+
_ -> Headers
102+
end.
103+
104+
-spec set_content_sha_header(internal_signer(), headers()) -> headers().
105+
set_content_sha_header(#internal_signer{payload_hash = PayloadHash, options = Options}, Headers) ->
106+
case PayloadHash of
107+
_ when byte_size(PayloadHash) > 0, Options#v4_signer_options.add_payload_hash_header =:= true ->
108+
headers_put(<<"X-Amz-Content-Sha256">>, payload_hash_string(PayloadHash), Headers);
109+
_ -> Headers
110+
end.
111+
112+
-spec headers_put(binary(), binary(), headers()) -> headers().
113+
headers_put(Key, Val, Headers) ->
114+
lists:keystore(Key, 1, Headers, {Key, Val}).
115+
116+
-spec build_canonical_request(internal_signer()) -> {binary(), binary()}.
117+
build_canonical_request(Signer) ->
118+
CanonMethod = build_canonical_method(Signer),
119+
CanonPath = build_canonical_path(Signer),
120+
CanonQuery = build_canonical_query(Signer),
121+
{CanonHeaders, SignedHeaders} = build_canonical_headers(Signer),
122+
{iolist_to_binary(
123+
lists:join("\n",
124+
[ CanonMethod
125+
, CanonPath
126+
, CanonQuery
127+
, CanonHeaders
128+
, SignedHeaders
129+
, payload_hash_string(Signer#internal_signer.payload_hash)
130+
])),
131+
SignedHeaders}.
132+
133+
-spec build_canonical_method(internal_signer()) -> binary().
134+
build_canonical_method(Signer) ->
135+
aws_sigv4_utils:toupper(Signer#internal_signer.request#request.method).
136+
137+
-spec build_canonical_path(internal_signer()) -> binary().
138+
build_canonical_path(Signer) ->
139+
URIMap = uri_string:parse(Signer#internal_signer.request#request.url),
140+
Path = maps:get(path, URIMap, <<>>),
141+
%% FIXME: we want an escaped path here, it's unclear if it already is
142+
EscapedPath =
143+
case byte_size(Path) =:= 0 of
144+
true -> <<"/">>;
145+
false -> Path
146+
end,
147+
case Signer#internal_signer.options#v4_signer_options.disable_double_path_escape of
148+
true -> EscapedPath;
149+
false -> aws_signature_utils:uri_encode_path(EscapedPath)
150+
end.
151+
152+
-spec build_canonical_query(internal_signer()) -> binary().
153+
build_canonical_query(Signer) ->
154+
URIMap = uri_string:parse(Signer#internal_signer.request#request.url),
155+
QueryString = maps:get(query, URIMap, <<"">>),
156+
QueryList0 = uri_string:dissect_query(QueryString),
157+
QueryMap0 =
158+
lists:foldl(
159+
fun({Key, Val0}, QMap) ->
160+
%% treat "key" like "key="
161+
Val = case Val0 of true -> <<>>; _ -> Val0 end,
162+
Vals = maps:get(Key, QMap, []),
163+
maps:put(Key, [Val | Vals], QMap)
164+
end, maps:new(), QueryList0),
165+
QueryMap1 =
166+
maps:map(
167+
fun(_Key, Vals) ->
168+
lists:reverse(Vals)
169+
end, QueryMap0),
170+
QueryList2 =
171+
lists:sort(
172+
fun({K1, _Vs1}, {K2, _Vs2}) ->
173+
K1 =< K2
174+
end, maps:to_list(QueryMap1)),
175+
QueryList3 =
176+
lists:append(
177+
lists:map(
178+
fun({K, Vs}) ->
179+
lists:map(fun(V) -> {K, V} end, Vs)
180+
end, QueryList2)),
181+
case QueryList3 of
182+
[] -> <<>>;
183+
_ -> binary:replace(uri_string:compose_query(QueryList3), <<"+">>, <<"%20">>)
184+
end.
185+
186+
-spec build_canonical_headers(internal_signer()) -> {binary(), binary()}.
187+
build_canonical_headers(Signer) ->
188+
IsSigned = Signer#internal_signer.options#v4_signer_options.is_signed,
189+
SignedHeadersMap =
190+
lists:foldl(
191+
fun({Header, Value}, Map) ->
192+
Lowercase = aws_sigv4_utils:tolower(Header),
193+
case IsSigned(Lowercase) of
194+
true ->
195+
Values = maps:get(Lowercase, Map, []),
196+
maps:put(Lowercase, [Value | Values], Map);
197+
false -> Map
198+
end
199+
end, maps:new(), Signer#internal_signer.request#request.headers),
200+
SignedHeadersList = lists:sort(maps:to_list(SignedHeadersMap)),
201+
SignedHeaders =
202+
iolist_to_binary(
203+
lists:map(
204+
fun({Header, Values}) ->
205+
[ Header
206+
, ":"
207+
, lists:join(",", lists:map(fun aws_sigv4_utils:trimspace/1, lists:reverse(Values)))
208+
, "\n"
209+
]
210+
end, SignedHeadersList)),
211+
CanonHeaders =
212+
iolist_to_binary(
213+
lists:join(";", lists:map(fun({Header, _Values}) -> Header end, SignedHeadersList))),
214+
{SignedHeaders, CanonHeaders}.
215+
216+
-spec build_string_to_sign(internal_signer(), binary()) -> binary().
217+
build_string_to_sign(Signer, CanonicalRequest) ->
218+
iolist_to_binary(
219+
lists:join("\n",
220+
[ Signer#internal_signer.algorithm
221+
, aws_sigv4_utils:format_time_long(Signer#internal_signer.time)
222+
, Signer#internal_signer.credential_scope
223+
, aws_sigv4_utils:sha256(CanonicalRequest)
224+
])).
225+
226+
-spec build_authorization_header(internal_signer(), binary(), binary()) -> binary().
227+
build_authorization_header(Signer, SignedHeaders, Signature) ->
228+
iolist_to_binary(
229+
[ Signer#internal_signer.algorithm
230+
, " Credential="
231+
, Signer#internal_signer.credentials#credentials.access_key_id
232+
, "/"
233+
, Signer#internal_signer.credential_scope
234+
, ", SignedHeaders="
235+
, SignedHeaders
236+
, ", Signature="
237+
, Signature
238+
]).
239+
240+
-spec payload_hash_string(binary()) -> binary().
241+
payload_hash_string(Hash) ->
242+
case Hash of
243+
?UNSIGNED_PAYLOAD -> Hash;
244+
_ -> aws_signature_utils:base16(Hash)
245+
end.
246+
247+
-spec resolve_time(calendar:datetime() | undefined) -> calendar:datetime().
248+
resolve_time(undefined) -> calendar:universal_time();
249+
resolve_time(Time) -> Time.
250+
251+
-spec default_is_signed(binary()) -> boolean().
252+
default_is_signed(Header) ->
253+
case Header of
254+
<<"host">> -> true;
255+
<<"x-amz-", _/binary>> -> true;
256+
_ -> false
257+
end.
258+
259+
-spec sign_string(internal_signer(), binary()) -> {ok, binary()} | {error, any()}.
260+
sign_string(Signer, String) ->
261+
(Signer#internal_signer.sign_string)(String).

src/aws_sigv4_internal.hrl

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
-ifndef(_AWS_SIGV4_INTERNAL_HRL_).
2+
-define(_AWS_SIGV4_INTERNAL_HRL_, true).
3+
4+
-type headers() :: [{binary(), binary()}].
5+
6+
-record(request,
7+
{ method :: binary()
8+
, url :: binary()
9+
, headers :: headers()
10+
, body :: binary()
11+
, host :: binary()
12+
}).
13+
14+
%% https://github.com/aws/smithy-go/blob/main/aws-http-auth/credentials/credentials.go
15+
16+
-record(credentials,
17+
{ access_key_id :: binary()
18+
, secret_access_key :: binary()
19+
, session_token :: binary()
20+
}).
21+
22+
%% https://github.com/aws/smithy-go/blob/main/aws-http-auth/v4/v4.go
23+
24+
-type is_signed() :: fun((binary()) -> boolean()).
25+
26+
-record(v4_signer_options,
27+
{ is_signed :: is_signed() | undefined
28+
, disable_implicit_payload_hashing = false :: boolean()
29+
, disable_double_path_escape = false :: boolean()
30+
, add_payload_hash_header = false :: boolean()
31+
}).
32+
33+
-define(UNSIGNED_PAYLOAD, <<"UNSIGNED-PAYLOAD">>).
34+
35+
%% https://github.com/aws/smithy-go/blob/main/aws-http-auth/internal/v4/signer.go
36+
37+
-type sign_string() :: fun((binary()) -> {ok, binary()} | {error, any()}).
38+
39+
-record(internal_signer,
40+
{ request :: aws_sigv4_internal:request()
41+
, payload_hash :: binary() % raw binary, NOT hex-encoded
42+
, time :: calendar:datetime()
43+
, credentials :: aws_sigv4_internal:credentials()
44+
, options :: aws_sigv4_internal:v4_signer_options()
45+
, algorithm :: binary()
46+
, credential_scope :: binary()
47+
, sign_string :: sign_string()
48+
}).
49+
50+
%% https://github.com/aws/smithy-go/blob/main/aws-http-auth/sigv4a/sigv4a.go
51+
52+
-record(v4a_sign_request_input,
53+
{ request :: aws_sigv4_internal:request()
54+
, payload_hash :: binary() % raw binary, NOT hex-encoded
55+
, credentials :: aws_sigv4_internal:credentials()
56+
, service :: binary()
57+
, regions :: [binary()]
58+
, time :: calendar:datetime() | undefined
59+
}).
60+
61+
-endif. % _AWS_SIGV4_INTERNAL_HRL_

0 commit comments

Comments
 (0)