%% ==================================================================== %% @author Gavin M. Roy %% @copyright 2016, Gavin M. Roy %% @private %% @doc httpc_aws request signing methods %% @end %% ==================================================================== -module(httpc_aws_sign). %% API -export([headers/1]). %% Export all for unit tests -ifdef(TEST). -compile(export_all). -endif. -include("httpc_aws.hrl"). -define(ALGORITHM, "AWS4-HMAC-SHA256"). -define(ISOFORMAT_BASIC, "~4.10.0b~2.10.0b~2.10.0bT~2.10.0b~2.10.0b~2.10.0bZ"). -spec headers(request()) -> headers(). %% @doc Create the signed request headers %% end headers(Request) -> RequestTimestamp = local_time(), PayloadHash = sha256(Request#request.body), URI = httpc_aws_urilib:parse(Request#request.uri), {_, Host, _} = URI#uri.authority, Headers = append_headers(RequestTimestamp, length(Request#request.body), PayloadHash, Host, Request#request.security_token, Request#request.headers), RequestHash = request_hash(Request#request.method, URI#uri.path, URI#uri.query, Headers, Request#request.body), AuthValue = authorization(Request#request.access_key, Request#request.secret_access_key, RequestTimestamp, Request#request.region, Request#request.service, Headers, RequestHash), sort_headers(lists:merge([{"authorization", AuthValue}], Headers)). -spec amz_date(AMZTimestamp :: string()) -> string(). %% @doc Extract the date from the AMZ timestamp format. %% @end amz_date(AMZTimestamp) -> [RequestDate, _] = string:tokens(AMZTimestamp, "T"), RequestDate. -spec append_headers(AMZDate :: string(), ContentLength :: integer(), PayloadHash :: string(), Hostname :: host(), SecurityToken :: security_token(), Headers :: headers()) -> list(). %% @doc Append the headers that need to be signed to the headers passed in with %% the request %% @end append_headers(AMZDate, ContentLength, PayloadHash, Hostname, SecurityToken, Headers) -> Defaults = default_headers(AMZDate, ContentLength, PayloadHash, Hostname, SecurityToken), Headers1 = [{string:to_lower(Key), Value} || {Key, Value} <- Headers], Keys = lists:usort(lists:append([string:to_lower(Key) || {Key, _} <- Defaults], [Key || {Key, _} <- Headers1])), sort_headers([{Key, header_value(Key, Headers1, proplists:get_value(Key, Defaults))} || Key <- Keys]). -spec authorization(AccessKey :: access_key(), SecretAccessKey :: secret_access_key(), RequestTimestamp :: string(), Region :: region(), Service :: string(), Headers :: headers(), RequestHash :: string()) -> string(). %% @doc Return the authorization header value %% @end authorization(AccessKey, SecretAccessKey, RequestTimestamp, Region, Service, Headers, RequestHash) -> RequestDate = amz_date(RequestTimestamp), Scope = scope(RequestDate, Region, Service), Credentials = ?ALGORITHM ++ " Credential=" ++ AccessKey ++ "/" ++ Scope, SignedHeaders = "SignedHeaders=" ++ signed_headers(Headers), StringToSign = string_to_sign(RequestTimestamp, RequestDate, Region, Service, RequestHash), SigningKey = signing_key(SecretAccessKey, RequestDate, Region, Service), Signature = string:join(["Signature", signature(StringToSign, SigningKey)], "="), string:join([Credentials, SignedHeaders, Signature], ", "). -spec default_headers(RequestTimestamp :: string(), ContentLength :: integer(), PayloadHash :: string(), Hostname :: host(), SecurityToken :: security_token()) -> headers(). %% @doc build the base headers that are merged in with the headers for every %% request. %% @end default_headers(RequestTimestamp, ContentLength, PayloadHash, Hostname, undefined) -> [{"content-length", integer_to_list(ContentLength)}, {"date", RequestTimestamp}, {"host", Hostname}, {"x-amz-content-sha256", PayloadHash}]; default_headers(RequestTimestamp, ContentLength, PayloadHash, Hostname, SecurityToken) -> [{"content-length", integer_to_list(ContentLength)}, {"date", RequestTimestamp}, {"host", Hostname}, {"x-amz-content-sha256", PayloadHash}, {"x-amz-security-token", SecurityToken}]. -spec canonical_headers(Headers :: headers()) -> string(). %% @doc Convert the headers list to a line-feed delimited string in the AWZ %% canonical headers format. %% @end canonical_headers(Headers) -> canonical_headers(sort_headers(Headers), []). -spec canonical_headers(Headers :: headers(), CanonicalHeaders :: list()) -> string(). %% @doc Convert the headers list to a line-feed delimited string in the AWZ %% canonical headers format. %% @end canonical_headers([], CanonicalHeaders) -> lists:flatten(CanonicalHeaders); canonical_headers([{Key, Value}|T], CanonicalHeaders) -> Header = string:join([string:to_lower(Key), Value], ":") ++ "\n", canonical_headers(T, lists:append(CanonicalHeaders, [Header])). -spec credential_scope(RequestDate :: string(), Region :: region(), Service :: string()) -> string(). %% @doc Return the credential scope string used in creating the request string to sign. %% @end credential_scope(RequestDate, Region, Service) -> lists:flatten(string:join([RequestDate, Region, Service, "aws4_request"], "/")). -spec header_value(Key :: string(), Headers :: headers(), Default :: string()) -> string(). %% @doc Return the the header value or the default value for the header if it %% is not specified. %% @end header_value(Key, Headers, Default) -> proplists:get_value(Key, Headers, proplists:get_value(string:to_lower(Key), Headers, Default)). -spec hmac_sign(Key :: string(), Message :: string()) -> string(). %% @doc Return the SHA-256 hash for the specified value. %% @end hmac_sign(Key, Message) -> binary_to_list(crypto:hmac(sha256, Key, Message)). -spec local_time() -> string(). %% @doc Return the current timestamp in GMT formatted in ISO8601 basic format. %% @end local_time() -> [LocalTime] = calendar:local_time_to_universal_time_dst(calendar:local_time()), local_time(LocalTime). -spec local_time(calendar:datetime()) -> string(). %% @doc Return the current timestamp in GMT formatted in ISO8601 basic format. %% @end local_time({{Y,M,D},{HH,MM,SS}}) -> lists:flatten(io_lib:format(?ISOFORMAT_BASIC, [Y, M, D, HH, MM, SS])). -spec query_string(QueryArgs :: list()) -> string(). %% @doc Return the sorted query string for the specified arguments. %% @end query_string(undefined) -> ""; query_string(QueryArgs) -> httpc_aws_urilib:build_query_string(lists:keysort(1, QueryArgs)). -spec request_hash(Method :: method(), Path :: path(), QArgs :: query_args(), Headers :: headers(), Payload :: string()) -> string(). %% @doc Create the request hash value %% @end request_hash(Method, Path, QArgs, Headers, Payload) -> EncodedPath = "/" ++ string:join([httpc_aws_urilib:percent_encode(P) || P <- string:tokens(Path, "/")], "/"), CanonicalRequest = string:join([string:to_upper(atom_to_list(Method)), EncodedPath, query_string(QArgs), canonical_headers(Headers), signed_headers(Headers), sha256(Payload)], "\n"), sha256(CanonicalRequest). -spec scope(AMZDate :: string(), Region :: region(), Service :: string()) -> string(). %% @doc Create the Scope string %% @end scope(AMZDate, Region, Service) -> string:join([AMZDate, Region, Service, "aws4_request"], "/"). -spec sha256(Value :: string()) -> string(). %% @doc Return the SHA-256 hash for the specified value. %% @end sha256(Value) -> lists:flatten(io_lib:format("~64.16.0b", [binary:decode_unsigned(crypto:hash(sha256, Value))])). -spec signed_headers(Headers :: list()) -> string(). %% @doc Return the signed headers string of delimited header key names %% @end signed_headers(Headers) -> signed_headers(sort_headers(Headers), []). -spec signed_headers(Headers :: headers(), Values :: list()) -> string(). %% @doc Return the signed headers string of delimited header key names %% @end signed_headers([], SignedHeaders) -> string:join(SignedHeaders, ";"); signed_headers([{Key,_}|T], SignedHeaders) -> signed_headers(T, SignedHeaders ++ [string:to_lower(Key)]). -spec signature(StringToSign :: string(), SigningKey :: string()) -> string(). %% @doc Create the request signature. %% @end signature(StringToSign, SigningKey) -> lists:flatten(io_lib:format("~64.16.0b", [binary:decode_unsigned(crypto:hmac(sha256, SigningKey, StringToSign))])). -spec signing_key(SecretKey :: secret_access_key(), AMZDate :: string(), Region :: region(), Service :: string()) -> string(). %% @doc Create the signing key %% @end signing_key(SecretKey, AMZDate, Region, Service) -> DateKey = hmac_sign("AWS4" ++ SecretKey, AMZDate), RegionKey = hmac_sign(DateKey, Region), ServiceKey = hmac_sign(RegionKey, Service), hmac_sign(ServiceKey, "aws4_request"). -spec string_to_sign(RequestTimestamp :: string(), RequestDate :: string(), Region :: region(), Service :: string(), RequestHash :: string()) -> string(). %% @doc Return the string to sign when creating the signed request. %% @end string_to_sign(RequestTimestamp, RequestDate, Region, Service, RequestHash) -> CredentialScope = credential_scope(RequestDate, Region, Service), lists:flatten(string:join([ ?ALGORITHM, RequestTimestamp, CredentialScope, RequestHash ], "\n")). -spec sort_headers(Headers :: headers()) -> headers(). %% @doc Case-insensitive sorting of the request headers %% @end sort_headers(Headers) -> lists:sort(fun({A,_}, {B, _}) -> string:to_lower(A) =< string:to_lower(B) end, Headers).