はじめに
Elixirには公式のAWS SDKが提供されていないので、ElixirからAWSのサービスにアクセスしたい場合は、他の人が作成したライブラリを使う必要があります。
ただ、AWSのサービスへのアクセス自体は認証情報を付与したHTTPリクエストで行われていて、また、サービスごとにそれぞれREST APIが公開され、認証についても共通の認証方式として「AWS Signature Version 4」が公開されているので、自作ライブラリを作ってAWSのサービスを利用することも可能です。
今回、Elixirのお勉強も兼ねて「AWS Signature Version 4」の実装を行い、HTTPリクエストにて実際にAWSのサービスにアクセスしてみます。
なお、今回のコードは下記で公開しています。
https://github.com/imahiro-t/aws_erin
実装してみる
下記ドキュメントによると、HTTPリクエストのAuthorizationヘッダーに設定する認証情報を取得するためには、いくつかのステップを踏む必要があるようです。
- Canonical Requestの作成
- String to Signの作成
- Signing Keyの生成とSignatureの計算
- Authorizationへの設定
上記のステップに従い、それぞれのドキュメントを読みながら実装を行ってみたいと思います。
1. Canonical Requestの作成
Canonical Requestのフォーマットについては下記の内容となります。
CanonicalRequest =
HTTPRequestMethod + '\n' +
CanonicalURI + '\n' +
CanonicalQueryString + '\n' +
CanonicalHeaders + '\n' +
SignedHeaders + '\n' +
HexEncode(Hash(RequestPayload))
HTTPRequestMethod
はGET
やPOST
、PUT
などのいわゆるリクエストメソッドです。
CanonicalURI
はホストの後ろからクエリパラメータの?
までにあるパス情報となります。パスが空の場合は/
を補ってあげる必要があります。
defp get_canonical_uri(path) when is_nil(path) or path == "", do: "/"
defp get_canonical_uri(path), do: path
CanonicalQueryString
はパラメータの名前と値をURIエンコーディングし、名前をキーに昇順でソートし、名前と値を=
で各パラメータを&
で連結して作成します。
defp get_canonical_query_string(query_params = %{}) do
query_params
|> Map.keys()
|> Enum.reduce(Map.new(), fn x, acc ->
acc |> Map.put(x |> URI.encode(), query_params |> Map.get(x) || "" |> URI.encode())
end)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(&"#{elem(&1, 0)}=#{elem(&1, 1)}")
|> Enum.join("&")
end
CanonicalHeaders
は、ヘッダーの名前を小文字にし、名前をキーに昇順でソートしてあげて、名前と値は:
で、各ヘッダーは\n
で連結します。値は前後のスペースは削除し、間に複数のスペースが連続している場合は、単一のスペースに変換して作成します。
defp get_canonical_headers(headers = %{}) do
headers
|> Enum.sort_by(&(elem(&1, 0) |> String.downcase()))
|> Enum.map(&get_canonical_header/1)
|> Enum.join()
end
defp get_canonical_header({key, value}) do
"#{key |> String.downcase()}:#{value |> String.trim() |> String.replace(~r/\s+/, " ")}\n"
end
SignedHeaders
は、ヘッダーの名前を小文字にし、名前をキーに昇順でソートしてあげて、名前のみを;
で連結して作成します。
defp get_signed_headers(headers = %{}) do
headers
|> Map.keys()
|> Enum.map(&String.downcase/1)
|> Enum.sort()
|> Enum.join(";")
end
HexEncode(Hash(RequestPayload))
はペーロードをSHA256でハッシュ化し16進数の小文字でエンコードして作成します。
defp get_hashed_payload(request_payload) do
request_payload |> hash() |> hex_encode()
end
defp hash(str \\ "") do
:crypto.hash(:sha256, str)
end
defp hex_encode(str \\ "") do
str
|> Base.encode16(case: :lower)
end
CanonicalRequest
は上記で作成したものを\n
で連結して作成します。
defp get_canonical_request(
%URI{path: path, query: query},
http_request_method,
headers,
request_payload
) do
http_request_method <>
"\n" <>
get_canonical_uri(path) <>
"\n" <>
get_canonical_query_string(URI.decode_query(query || "")) <>
"\n" <>
get_canonical_headers(headers) <>
"\n" <>
get_signed_headers(headers) <>
"\n" <>
get_hashed_payload(request_payload)
end
2. String to Signの作成
StringToSign
のフォーマットは下記の内容となります。
StringToSign =
Algorithm + \n +
RequestDateTime + \n +
CredentialScope + \n +
HashedCanonicalRequest
Algorithm
はAWS4-HMAC-SHA256
で固定です。
RequestDateTime
はISO8601の基本フォーマットであるYYYYMMDD'T'HHMMSS'Z'
となります。
CredentialScope
はYYYYMMDD
フォーマットの日付とリージョン名、サービス名、aws4_request
という固定文字列を/
で連結して作成します。
@terminator "aws4_request"
defp get_credential_scope(region_name, service_name, date_time) do
"#{date_time |> get_date()}/#{region_name}/#{service_name}/#{@terminator}"
end
defp get_date(date_time), do: date_time |> String.slice(0..7)
HashedCanonicalRequest
は1で作成したCanonicalRequest
をSHA256でハッシュ化し16進数の小文字でエンコードして作成します。
defp get_hashed_canonical_request(canonical_request) do
canonical_request |> hash() |> hex_encode()
end
StringToSign
は上記で作成したものを\n
で連結して作成します。
@algorithm "AWS4-HMAC-SHA256"
defp get_string_to_sign(region_name, service_name, date_time, canonical_request) do
credential_scope = get_credential_scope(region_name, service_name, date_time)
hashed_canonical_request = get_hashed_canonical_request(canonical_request)
"#{@algorithm}\n#{date_time}\n#{credential_scope}\n#{hashed_canonical_request}"
end
3. Signing Keyの生成とSignatureの計算
SigningKey
はAWSのシークレットアクセスキーを基に、下記のように何段階かHMAC-SHA256でハッシュ化を行い別のキーを作成します。
kSecret = AWS_SECRET_ACCESS_KEY
kDate = HMAC("AWS4" + kSecret, Date)
kRegion = HMAC(kDate, Region)
kService = HMAC(kRegion, Service)
kSigning = HMAC(kService, "aws4_request")
defp derive_signing_key(aws_secret_key, region_name, service_name, date_time) do
hmac("AWS4#{aws_secret_key}", date_time |> get_date())
|> hmac(region_name)
|> hmac(service_name)
|> hmac(@terminator)
end
defp hmac(secret, str) do
:crypto.hmac(:sha256, secret, str)
end
作成されたSigningKey
をキーとして、2で作成したStringToSign
をHMAC-SHA256でハッシュ化し16進数の小文字でエンコードし、Signatureを作成します。
defp calcurate_signature(signing_key, string_to_sign) do
signing_key |> hmac(string_to_sign) |> hex_encode()
end
4. Authorizationへの設定
Authorizationヘッダーに設定する内容は下記のフォーマットになります。
Algorithm Credential=AWS_ACCESS_KEY_ID/CredentialScope, SignedHeaders=SignedHeaders, Signature=Signature
必要な情報は上のステップで全て揃うので、Authorizationヘッダーに設定する内容は下記の関数で取得することができます。
def get_authorization(
aws_access_key,
aws_secret_key,
endpoint_uri,
http_request_method,
region_name,
service_name,
date_time,
headers,
request_payload
) do
region_name = region_name || "us-east-1"
canonical_request =
get_canonical_request(endpoint_uri, http_request_method, headers, request_payload)
string_to_sign = get_string_to_sign(region_name, service_name, date_time, canonical_request)
signing_key = derive_signing_key(aws_secret_key, region_name, service_name, date_time)
signature = calcurate_signature(signing_key, string_to_sign)
credentials_authorization_header =
"Credential=#{aws_access_key}/#{get_credential_scope(region_name, service_name, date_time)}"
signed_headers_authorization_header = "SignedHeaders=#{get_signed_headers(headers)}"
signature_authorization_header = "Signature=#{signature}"
"#{@algorithm} #{credentials_authorization_header}, #{signed_headers_authorization_header}, #{
signature_authorization_header
}"
end
動作確認してみる
AWSのサービスに共通してHTTPリクエストを行う関数は下記のような感じで書くことになります。
def request(
endpoint_uri = %URI{host: host},
http_request_method,
region_name,
service_name,
headers,
request_payload \\ ""
) do
aws_access_key = fetch_env!(:aws_access_key_id)
aws_secret_key = fetch_env!(:aws_secret_access_key)
date_time = get_date_time()
headers =
headers
|> Map.put("x-amz-date", date_time)
|> Map.put("Host", host)
headers =
if(request_payload,
do: headers |> Map.put("x-amz-content-sha256", get_hashed_payload(request_payload)),
else: headers
)
authorization =
get_authorization(
aws_access_key,
aws_secret_key,
endpoint_uri,
http_request_method,
region_name,
service_name,
date_time,
headers,
request_payload
)
headers =
headers
|> Map.put("Authorization", authorization)
case HTTPoison.request(http_request_method, endpoint_uri, request_payload, headers) do
{:ok, %{body: body}} -> body
{:error, %{reason: reason}} -> {:error, reason}
end
end
defp fetch_env!(key) do
Application.fetch_env!(:aws_erin, key)
end
defp get_hashed_payload(request_payload) do
request_payload |> Util.hash() |> Util.hex_encode()
end
defp get_date_time do
DateTime.utc_now() |> DateTime.to_iso8601(:basic) |> String.replace(~r/\.\d+/, "")
end
Authorizationを取得する関数を呼び出す際に、ヘッダー情報にはx-amz-date
にISO8601の基本フォーマットであるYYYYMMDD'T'HHMMSS'Z'
を、Host
にホスト名を、ペイロードがある場合は、x-amz-content-sha256
にペイロードをSHA256でハッシュ化し16進数の小文字でエンコードしたものを設定します。
上記のリクエストメソッドを使うことでAWSの各サービスにアクセスすることができ、例えばS3のバケット内のオブジェクトのリスト取得は下記のような感じで書くことができます。
def list_s3_objects(bucket_name, region_name) do
endpoint_uri = %URI{
host: "s3.#{region_name}.amazonaws.com",
path: "/#{bucket_name}/",
port: 443,
scheme: "https"
}
headers = Map.new()
request(endpoint_uri, "GET", region_name, "s3", headers)
end
また、DynamoDBのテーブルのリスト取得は下記のような感じで書くことができます。
def list_tables(region_name) do
endpoint_uri = %URI{
host: "dynamodb.#{region_name}.amazonaws.com",
port: 443,
scheme: "https"
}
headers =
Map.new()
|> Map.put("Content-Type", "application/x-amz-json-1.0")
|> Map.put("X-Amz-Target", "DynamoDB_20120810.ListTables")
request(endpoint_uri, "POST", region_name, "dynamodb", headers, "{}")
end
さいごに
「AWS Signature Version 4」で作成した認証情報をAuthorizationヘッダーに設定しリクエストすることで、恐らくAWSの大半のサービスにアクセスできるのかなと思います。
ただ、S3のオブジェクトのリストはXML形式で返ってくるのに対し、DynamoDBのテーブルのリストはJSON形式で返ってきたりするなど、AWSのサービスごとに取得できる値のフォーマットやエラーのフォーマットが異なっているようなので、ElixirのAWS SDKを作るには相当長い道のりになりそうです。