AWS SDK を使わずにAPIを実行する (SigV4 / PHP版)

## 概要
日頃からAWSの各サービスを使うにあたり、SDKの恩恵を受けているが、自分自身内部でどの様にAPIを実行しているかをあまり理解していなかった。そこで今回は、技術的な好奇心のためSDKの力を使わずにAPIを実行するところまでを行ってみたので、その備忘録として残す。
なお、本記事では PHP の場合での実装を示す。

## SigV4
AWS側に各種APIリクエストを実行する場合に、AWS側からどのクライアントからの送信かを判別できるように署名をして送信している。
AWSで提供している署名のバージョンは SigV2 と SigV4 の2種類が存在しており、現在では SigV4 が推奨されている。

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

署名してリクエストするには次のステップを踏む必要がある

* Step1. 正規リクエストの作成
* Step2. SigV4にあった署名の作成
* Step3. SigV4署名の計算
* Step4. HTTP header に付加
* Step5. curl などのクライアントでリクエストを実行

なお、本記事では Step2,3,4 を一つのステップとして扱い、3つのステップとして記載した。

また、各ステップで行っていることは後述しているが、より細かく確認したい場合はこちらを確認されたい。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4_signing.html

## 実装
ある程度ステップにそって、わかりやすく実装すると以下のような形になる。

```php
function send_api_request($url, $params, $service, $region, $method) {
  $credentials = Array(
    'AccessKeyId' => 'access_key', 
    'SecretKey' => 'access_secret',
    'SecurityToken' => 'security_token'  // 一時的に発行した TOKEN であれば必須
    );
  $request_headers = signature_request_v4($method, $url, $service, $region, $params, $credentials);

  $ch = curl_init($url);
  $header = array();
  foreach( $request_headers as $param => $value ) {
    $header[] = $param . ": " . $value;
  }

  curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
  curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, REQUEST_TIME_OUT);
  curl_setopt($ch, CURLOPT_TIMEOUT, REQUEST_TIME_OUT);
  curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
  curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
  $response = curl_exec($ch);
  $response_info = curl_getinfo($ch);

  if ($response === false) {
    return false;
  }

  $response_data = [
    'status_code' => $response_info['http_code'],
    'body' => $response
  ];

  return $response_data;
}

function signature_request_v4($method, $url, $service, $region, $request, $credentials){
  $now = time();
  $long_date   = date("Ymd\THis\Z", strtotime( '-9 Hours', $now));
  $short_date  = date("Ymd", strtotime( '-9 Hours', $now));
  $credential_scope = "{$short_date}/{$region}/{$service}/aws4_request";
  $host = parse_url($url, PHP_URL_HOST);
  $path = parse_url($url, PHP_URL_PATH);
  $payload = json_encode($request);

  $headers = [];
  $headers['Content-type'] = 'application/json';
  $headers['host'] = $host;
  $headers['x-amz-date'] = $long_date;
  if ($token = $credentials['SecurityToken'] {
    $headers['x-amz-security-token'] = $token;
  }

  // the signing key
  $key_secret    = 'AWS4' . $credentials['SecretKey'];
  $key_date      = hash_hmac('sha256', $short_date, $key_secret, true);
  $key_region    = hash_hmac('sha256', $region, $key_date, true);
  $key_service   = hash_hmac('sha256', $service, $key_region, true);
  $key_signing   = hash_hmac('sha256', 'aws4_request', $key_service, true);

  $canonical_request  = $this->create_canonical_request($headers, $payload, $method, $host, $path);
  $signed_request     = hash('sha256', $canonical_request);
  $sign_string        = "AWS4-HMAC-SHA256\n{$long_date}\n{$credential_scope}\n" .$signed_request;
  $signature          = hash_hmac('sha256', $sign_string, $key_signing, true);
  $headers['authorization'] = "AWS4-HMAC-SHA256 Credential={$credentials['AccessKeyId']}/{$credential_scope}, " .
                              "SignedHeaders=" . implode(";", array_keys($headers))  . ", " .
                              "Signature=" . bin2hex($signature);
  
  return $headers;
}

function create_canonical_request( Array $headers, $payload, $method, $host, $path)
{
  $canonical_request      = array();
  $canonical_request[]    = $method;
  $canonical_request[]    = $path;
  $canonical_request[]    = '';
  foreach($headers as $key => $value)
    $canonical_headers[ strtolower($key) ] = trim($value);
  uksort($canonical_headers, 'strcmp');
  foreach($canonical_headers as $key => $value) {
    $canonical_request[] = $key . ':' . $value;
  }
  $canonical_request[] = '';
  $canonical_request[] = implode(';', array_keys($canonical_headers));
  $canonical_request[] = hash('sha256', $payload);
  $canonical_request = implode("\n", $canonical_request);

  return $canonical_request;
}
```


## 詳細
### 準備
まずはヘッダ情報を作成するために準備を行う。
署名やリクエストを作成するにあたり必要なパラメータを定義する。

* 認証スコープ(`credential_scope`)や日時(`long_date`, `short_date`)の部分はSigV4の署名の文字列作成や計算の部分で使用する
* それ以外の情報に関しては正規のリクエストを作成するときに使用する

```php
  $now = time();
  $long_date   = date("Ymd\THis\Z", strtotime( '-9 Hours', $now));
  $short_date  = date("Ymd", strtotime( '-9 Hours', $now));
  $credential_scope = "{$short_date}/{$region}/{$service}/aws4_request";
  $host = parse_url($url, PHP_URL_HOST);
  $path = parse_url($url, PHP_URL_PATH);
  $payload = json_encode($request);

  $headers = [];
  $headers['Content-type'] = 'application/json';
  $headers['host'] = $host;
  $headers['x-amz-date'] = $long_date;
  if ($token = $credentials['SecurityToken'] {
    $headers['x-amz-security-token'] = $token;
  }
```


### Step1. SigV4の正規リクエストを作成する
SigV4で用いる正規のリクエストを作成する。
正規リクエストを作成することで、クライアント側で作成したリクエストとAWS側で作成したリクエスト情報で同一の値として計算を行える。
正規化されたリクエストは以下の形式で示されている。

```
  HTTPRequestMethod // リクエストメソッド (POST, GET)
  CanonicalURI // URI (httpエンコードされた絶対パス)
  CanonicalQueryString // クエリ値
  CanonicalHeaders // ヘッダ（各要素の値を前後のスペースを削除して、連続したスペースを１つのみに置き換えした上、キーを小文字に変換。並び順をアルファベット順にしたもの）
  SignedHeaders // ヘッダのkeyを ; で羅列したもの
  HexEncode(Hash(RequestPayload)) // ペイロード
```

`CanonicalQueryString` に関しては一定のルールのもとでURIエンコードをした上で生成する

* キーは文字コードベースで昇順にソートを行う
* `A ~ Z、a ~ z、0 ～ 9、-、_、.、~` は変換しない　
* キーと値にある上記以外の文字列に関してはURIエンコードを行う

ペイロードの内容やURLのホスト、パス、メソッドなどの情報を追加する必要があるため、ある程度ヘッダを作成したうえでそのヘッダ情報に追加する形で作成すると分かりやすくなる。
そこで、本記事では、メソッド化をして、ヘッダ作成後に正規リクエストを作成できるような形にした。

```php
function create_canonical_request( Array $headers, $payload, $method, $host, $path)
{
  $canonical_request      = array();
  $canonical_request[]    = $method;
  $canonical_request[]    = $path;
  $canonical_request[]    = '';
  foreach($headers as $key => $value)
    $canonical_headers[ strtolower($key) ] = trim($value);
  uksort($canonical_headers, 'strcmp');
  foreach($canonical_headers as $key => $value) {
    $canonical_request[] = $key . ':' . $value;
  }
  $canonical_request[] = '';
  $canonical_request[] = implode(';', array_keys($canonical_headers));
  $canonical_request[] = hash('sha256', $payload);
  $canonical_request = implode("\n", $canonical_request);

  return $canonical_request;
}
```

### Step2. SigV4の署名文字列を作成し計算・追加する

署名文字列を作成する場合には以下の形式で作成する必要がある

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

上記実装では各変数を1行単位で実装しているが以下の3行で上記文字列を作成することが出来る。

```php
  $canonical_request  = create_canonical_request($headers, $payload, $method, $host, $path);
  $signed_request     = hash('sha256', $canonical_request);
  $sign_string        = "AWS4-HMAC-SHA256\n{$long_date}\n{$credential_scope}\n" .$signed_request;
```

`$sign_string` で先に示した形式に沿った形で連結をしている

```
AWS4-HMAC-SHA256\n // アルゴリズム
{$long_date}\n // Ymd\THis\Z の形式での時間
{$credential_scope}\n // {$short_date(Ymd形式での時間)}/{$region}/{$service}/aws4_request
$signed_request //正規リクエストを sha256 でハッシュ化したもの。
```

次に作成した署名を計算するが、`hash_hmac` を用いて生成するため、必要な秘密鍵を生成する。
作成方法に関しては AWS 側のドキュメントを引用した。

```
kSecret = your secret access key
kDate = HMAC("AWS4" + kSecret, Date)
kRegion = HMAC(kDate, Region)
kService = HMAC(kRegion, Service)
kSigning = HMAC(kService, "aws4_request")
```
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-calculate-signature.html


```php
  // the signing key
  $key_secret    = 'AWS4' . $credentials['SecretKey'];
  $key_date      = hash_hmac('sha256', $short_date, $key_secret, true);
  $key_region    = hash_hmac('sha256', $region, $key_date, true);
  $key_service   = hash_hmac('sha256', $service, $key_region, true);
  $key_signing   = hash_hmac('sha256', 'aws4_request', $key_service, true);
```

作成した秘密鍵を用いて、署名文字列を計算する、結果はバイナリ形式で出力されるため hex 形式に変換をする。
authorization のヘッダに先ほど作成した情報を使用してヘッダを追加する。
ヘッダ形式は AWS側で以下の様に指定されている。

```
Authorization: algorithm Credential=access key ID/credential scope, SignedHeaders=SignedHeaders, Signature=signature
```

上記の形をphp に落とし込むと以下のような形にする。

```php
  $signature          = hash_hmac('sha256', $sign_string, $key_signing, true);
  $headers['authorization'] = "AWS4-HMAC-SHA256 Credential={$credentials['AccessKeyId']}/{$credential_scope}, " .
                              "SignedHeaders=" . implode(";", array_keys($headers))  . ", " .
                              "Signature=" . bin2hex($signature);
  
  return $headers;
```

### Step3. cURL で実行する
最後にphpで cURL を実行して取得されたレスポンスを成形する

```php
  $request_headers = signature_request_v4($method, $url, $service, $region, $params, $credentials);

  $ch = curl_init($url);
  $header = array();
  foreach( $request_headers as $param => $value ) {
    $header[] = $param . ": " . $value;
  }

  curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
  curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, REQUEST_TIME_OUT);
  curl_setopt($ch, CURLOPT_TIMEOUT, REQUEST_TIME_OUT);
  curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
  curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
  $response = curl_exec($ch);
  $response_info = curl_getinfo($ch);

  if ($response === false) {
    return false;
  }

  $response_data = [
    'status_code' => $response_info['http_code'],
    'body' => $response
  ];

  return $response_data;
}

```


## 参考
* AWSAPI リクエストの署名
https://docs.aws.amazon.com/ja_jp/general/latest/gr/signing_aws_api_requests.html

* [ PHP ] AWS 署名バージョン 4
https://memo.abridge-lab.com/?p=173

* https://gist.github.com/Saeven/7832090