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

# はじめに

Elixirには公式のAWS SDKが提供されていないので、ElixirからAWSのサービスにアクセスしたい場合は、他の人が作成したライブラリを使う必要があります。

ただ、AWSのサービスへのアクセス自体は認証情報を付与したHTTPリクエストで行われていて、また、サービスごとにそれぞれREST APIが公開され、認証についても共通の認証方式として「AWS Signature Version 4」が公開されているので、自作ライブラリを作ってAWSのサービスを利用することも可能です。

https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html

今回、Elixirのお勉強も兼ねて「AWS Signature Version 4」の実装を行い、HTTPリクエストにて実際にAWSのサービスにアクセスしてみます。

なお、今回のコードは下記で公開しています。
https://github.com/imahiro-t/aws_erin

# 実装してみる

下記ドキュメントによると、HTTPリクエストのAuthorizationヘッダーに設定する認証情報を取得するためには、いくつかのステップを踏む必要があるようです。

https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html

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

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

## 1. Canonical Requestの作成

https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html

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

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

`HTTPRequestMethod`は`GET`や`POST`、`PUT`などのいわゆるリクエストメソッドです。

`CanonicalURI`はホストの後ろからクエリパラメータの`?`までにあるパス情報となります。パスが空の場合は`/`を補ってあげる必要があります。

```elixir
defp get_canonical_uri(path) when is_nil(path) or path == "", do: "/"
defp get_canonical_uri(path), do: path
```

`CanonicalQueryString`はパラメータの名前と値をURIエンコーディングし、名前をキーに昇順でソートし、名前と値を`=`で各パラメータを`&`で連結して作成します。

```elixir
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`で連結します。値は前後のスペースは削除し、間に複数のスペースが連続している場合は、単一のスペースに変換して作成します。

```elixir
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`は、ヘッダーの名前を小文字にし、名前をキーに昇順でソートしてあげて、名前のみを`;`で連結して作成します。

```elixir
defp get_signed_headers(headers = %{}) do
  headers
  |> Map.keys()
  |> Enum.map(&String.downcase/1)
  |> Enum.sort()
  |> Enum.join(";")
end
```

`HexEncode(Hash(RequestPayload))`はペーロードをSHA256でハッシュ化し16進数の小文字でエンコードして作成します。

```elixir
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`で連結して作成します。

```elixir
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の作成

https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html

`StringToSign`のフォーマットは下記の内容となります。

```
StringToSign =
    Algorithm + \n +
    RequestDateTime + \n +
    CredentialScope + \n +
    HashedCanonicalRequest
```

`Algorithm`は`AWS4-HMAC-SHA256`で固定です。

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

`CredentialScope`は`YYYYMMDD`フォーマットの日付とリージョン名、サービス名、`aws4_request`という固定文字列を`/`で連結して作成します。

```elixir
@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`は１で作成した`CanonicalRequest`をSHA256でハッシュ化し16進数の小文字でエンコードして作成します。

```elixir
defp get_hashed_canonical_request(canonical_request) do
  canonical_request |> hash() |> hex_encode()
end
```

`StringToSign`は上記で作成したものを`\n`で連結して作成します。

```elixir
@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の計算

https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html

`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")
```

```elixir
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`をキーとして、２で作成した`StringToSign`をHMAC-SHA256でハッシュ化し16進数の小文字でエンコードし、Signatureを作成します。

```elixir
defp calcurate_signature(signing_key, string_to_sign) do
  signing_key |> hmac(string_to_sign) |> hex_encode()
end
```

## 4. Authorizationへの設定

https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html

Authorizationヘッダーに設定する内容は下記のフォーマットになります。

```
Algorithm Credential=AWS_ACCESS_KEY_ID/CredentialScope, SignedHeaders=SignedHeaders, Signature=Signature
```

必要な情報は上のステップで全て揃うので、Authorizationヘッダーに設定する内容は下記の関数で取得することができます。

```elixir
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リクエストを行う関数は下記のような感じで書くことになります。

```elixir
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のバケット内のオブジェクトのリスト取得は下記のような感じで書くことができます。

https://docs.aws.amazon.com/AmazonS3/latest/API/v2-RESTBucketGET.html

```elixir
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のテーブルのリスト取得は下記のような感じで書くことができます。

https://docs.aws.amazon.com/en_pv/amazondynamodb/latest/APIReference/API_ListTables.html

```elixir
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を作るには相当長い道のりになりそうです。