LoginSignup
8
5

More than 3 years have passed since last update.

AWS Signature Version 4をElixirで実装してみる

Last updated at Posted at 2019-09-21

はじめに

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ヘッダーに設定する認証情報を取得するためには、いくつかのステップを踏む必要があるようです。

  1. Canonical Requestの作成
  2. String to Signの作成
  3. Signing Keyの生成とSignatureの計算
  4. Authorizationへの設定

上記のステップに従い、それぞれのドキュメントを読みながら実装を行ってみたいと思います。

1. Canonical Requestの作成

Canonical Requestのフォーマットについては下記の内容となります。

CanonicalRequest =
    HTTPRequestMethod + '\n' +
    CanonicalURI + '\n' +
    CanonicalQueryString + '\n' +
    CanonicalHeaders + '\n' +
    SignedHeaders + '\n' +
    HexEncode(Hash(RequestPayload))

HTTPRequestMethodGETPOSTPUTなどのいわゆるリクエストメソッドです。

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

AlgorithmAWS4-HMAC-SHA256で固定です。

RequestDateTimeはISO8601の基本フォーマットであるYYYYMMDD'T'HHMMSS'Z'となります。

CredentialScopeYYYYMMDDフォーマットの日付とリージョン名、サービス名、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を作るには相当長い道のりになりそうです。

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5