JavaでAWS Signature V4を生成してAPIをリクエストする

# はじめに
AWS APIは、リクエストにAWS Signature V4という署名をつけることでIAM認証を利用できます。普通はAWS SDKを利用することでSignature V4の仕様をさほど意識する必要はないのですが、諸事情で、自前によるSignature V4実装をする機会がありましたので、メモを残します。

Java 11で実装しており、HTTPクライアントは[Apache HttpComponents Client](https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient)を使ってます。

 コードは切り貼り、加工しているので、正しく動作するか若干怪しいです。


# 署名しない時のリクエスト
　わかりやすくするため、署名しないときのリクエストサンプルを示します。 Elasticsearch APIで検索リクエストするサンプルです。このサンプルではPOSTメソッドを利用しているためクエリパラメータはHTTP bodyに書いています。

このリクエストにAWS Signature V4署名を施し、認証されたユーザからのみAPIを受けるように改修していきます。

```java
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.HttpClientBuilder;

void reqFunc(){
    // リクエストの生成
    URI uri                 = new URI(AWS_ELASTICSEARCH_URL + "/index/hoge/_search?");
	HttpPost post           = new HttpPost(uri);
	post.setHeader("Content-Type", "application/json");
	post.setEntity(new StringEntity( "クエリー文字列(省略)", "UTF-8"));

    // HTTPクライアントでリクエストの実行
    HttpClient client       = HttpClientBuilder.create().build();
    CloseableHttpResponse res = (CloseableHttpResponse) client.execute(post);

    // ...レスポンスに対する処理...
}
```

# 署名に関する情報
 AWS Signature V4はAmazonのWebサイトで情報提供がされています。下記のURLからたどって情報を得ました。
 https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-version-4.html

 また、認証に必要となるアクセスキーIDとシークレットアクセスキーの取得方法は、下記のURLからたどって情報を得ています。
 https://docs.aws.amazon.com/ja_jp/general/latest/gr/aws-sec-cred-types.html


# 署名の実装
 AWS Signature V4は、リクエスト情報そのものと、予め払い出されているアクセスキーID/シークレットアクセスキーを使いハッシュを生成し、リクエストヘッダに付与します。何度も繰り返しハッシュ化したり、ハッシュの対象対象がどこまでなのか分からず、試行錯誤しました。

## HttpRequestInterceptorクラスの作成
 最初に、送信するリクエスト自身の情報を取得するため、HttpRequestInterceptorクラスと、それを挟み込んだHTTPクライアントを用意します。HTTPクライアントを生成する際にこのクラスを挟むことで、サーバへのリクエスト送信直前に処理を差し込むことができます。

- HttpRequestInterceptor

```java
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.protocol.HttpContext;

public class AmazonizeInterceptor implements HttpRequestInterceptor {

	@Override
	public void process(HttpRequest request, HttpContext context) throws Exception{
        AwsSigner4(request);  
	}

    private void AwsSigner4(HttpRequest request) throws Exception {
        /* この関数実装内でAWS Signature V4の実装をします。 */
    }
}
```

- HTTPクライアント呼び出し部の修正

```java
//...略...

void reqFunc(){
    //...略...

    // HTTPクライアントでリクエストの実行
    HttpClient client = HttpClientBuilder.create()
                        .addInterceptorLast(new AmazonizeInterceptor())  // ←これを追加する
                        .build();
    CloseableHttpResponse res = (CloseableHttpResponse) client.execute(post);

    //...レスポンスに対する処理...
}
```

これで、RequestInterceptorを挟み込んだHTTPクライアントでリクエストを行うと、サーバ送信前に先程定義したAwsSigner4関数が実行されるようになります。

## AwsSigner4関数の実装

次に実際の署名処理を行うAwsSigner4関数の実装をしていきます。
処理内容は、リクエストヘッダーに「X-Amz-Date」と「Authorization」を追加するだけですが、Authorizationの値を求めるのに何段階か必要になります。

  1. 正規リクエスト文字列(canonicalRequest)の生成
  2. 署名文字列(StringToSign)の生成
  3. 署名キー(SigningKey)の生成
  4. Authorizationヘッダー文字列の生成

参考までに、canonicalRequestに含まれるヘッダー情報は、host,x-amz-dateが必須ですが、それ以外のヘッダー情報を追加しても良いみたいです。

```java
import org.apache.http.util.EntityUtils;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpRequest;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.text.SimpleDateFormat;

private void AwsSigner4(HttpRequest request) throws Exception {

    /* X-Amz-Dateヘッダーの生成 */
    SimpleDateFormat xAmzDateFormatter	= new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
    xAmzDateFormatter.setTimeZone(Calendar.getInstance(TimeZone.getTimeZone("UTC"))); // Signerの有効期限はUTCで判定される
    String xAmzDate 		= xAmzDateFormatter.format(new Date()).trim();
    request.setHeader("X-Amz-Date", xAmzDate);

    /* Authorizationヘッダー文字列の生成 */
    /* 1. 正規リクエスト文字列(canonicalRequest)の生成 */
    String path 			= getPath(request);
    String xAmzContentSha   = getBodyHash(request);
    String canonicalRequest = "POST\n"
                        + path + "\n"
                        + "\n"
                        + "host:" + request.getFirstHeader("Host").getValue() + "\n"
                        + "x-amz-date:" + xAmzDate + "\n"
                        + "\n"
                        + "host;x-amz-date\n"
                        + xAmzContentSha;

    /* 2. 署名文字列(StringToSign)の生成 */
    String awsRegion = "ap-northeast-1" ;  // AWS APIリクエスト先のリージョン情報
    String awsNameSpace = "es" ;  // リクエストするAWSサービスの名前空間
    String StringToSign = "AWS4-HMAC-SHA256\n"
                        + xAmzDate + "\n"
                        + xAmzDate.substring(0, 8) + "/" + awsRegion 
                        + "/" + awsNameSpace +"/aws4_request\n"
                        + DigestUtils.sha256Hex(canonicalRequest);

    /* 3. 署名キー(SigningKey)の生成 */
    String awsSecretAccessKey = "AWSシークレットアクセスキー" ;
    // X-Amz-Date → リージョン → AWSサービス名前空間 → 固定文字(aws4_request) の順でハッシュ化していく
    String hashStr    = getHmacSha256ByStrKey("AWS4" + awsSecretAccessKey, xAmzDate.substring(0, 8));
    hashStr           = getHmacSha256ByHexKey(hashStr, awsRegion);
    hashStr           = getHmacSha256ByHexKey(hashStr, awsNameSpace);
    String SigningKey = getHmacSha256ByHexKey(hashStr, "aws4_request");

    /* 4. Authorizationヘッダー文字列の生成 */
    String awsAccessKeyId = "AWSアクセスキーID" ;
    String sig 			= getHmacSha256ByHexKey(SigningKey, StringToSign);
    String authorization = "AWS4-HMAC-SHA256 Credential="
                        + awsAccessKeyId
                        + "/" + xAmzDate.substring(0, 8)
                        + "/" + awsRegion
                        + "/" + awsNameSpace
                        + "/aws4_request,"
                        + "SignedHeaders=host;x-amz-date,"
                        + "Signature=" + sig;

    request.setHeader("Authorization", authorization);
}

/***  リクエストパスの取得 */
private String getPath(HttpRequest req) throws Exception {
    String uri				= req.getRequestLine().getUri();

    // URLのクエリ文字列とのセパレータである「?」はpathに含めない
    if (uri.endsWith("?"))  uri = uri.substring(0, uri.length()-1);
    
    // URLエンコードも色々種類があるらしく、Amazonが指定したロジックでエンコードする
    return awsUriEncode(uri,true);
}

/*** リクスとボディのハッシュ取得 */
private String getBodyHash(HttpRequest req) throws Exception{
    HttpEntityEnclosingRequest ereq = (HttpEntityEnclosingRequest) req;
    String body				= EntityUtils.toString(ereq.getEntity());
    return DigestUtils.sha256Hex(body);
}

/***
    * AWS指定スペックのURLエンコーダ
    * @param input
    * @param encodeSlash
    * @return
    * @throws UnsupportedEncodingException
    */
private String awsUriEncode(CharSequence input, boolean encodeSlash) throws UnsupportedEncodingException {
    StringBuilder result = new StringBuilder();
    boolean queryIn = false;
    for (int i = 0; i < input.length(); i++) {
        char ch = input.charAt(i);
        if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '~' || ch == '.') {
            result.append(ch);
        } else if (ch == '/') {
            if (queryIn) result.append(encodeSlash ? "%2F" : ch);
            else result.append(ch);
        } else {
            if(!queryIn && ch=='?') {
                queryIn = true;
                result.append(ch);
            }else {
                byte[] bytes = new String(new char[] {ch}).getBytes("UTF-8");
                result.append("%" + Hex.encodeHexString(bytes,false));
            }
        }
    }
    return result.toString();
}

private String getHmacSha256ByStrKey(String strkey, String target) throws Exception {
    return getHmacSha256(strkey.getBytes(), target);
}

private String getHmacSha256ByHexKey(String hexkey, String target) throws Exception {
    return getHmacSha256(Hex.decodeHex(hexkey), target);
}

/***
    * target文字列をKeyを使いHMAC-SHA-256にハッシュ化する
    * @param target ハッシュ対象
    * @param key キー
    * @return Hex形式のハッシュ値
    */
private String getHmacSha256(byte[] key, String target) throws Exception {
    final Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    return String.valueOf(Hex.encodeHex(mac.doFinal(target.getBytes()), true));
}

```

# まとめ

最終的にまとめると下記のようになりました。
リクエスト毎にフォーマッターを生成したり、定数が埋め込みになっていたりでアレですが、そこら辺はよしなに直してください。

```java
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.codec.binary.Hex;
import java.text.SimpleDateFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class AmazonizeInterceptor implements HttpRequestInterceptor {

	@Override
	public void process(HttpRequest request, HttpContext context) throws Exception{
        AwsSigner4(request);  
	}

    private void AwsSigner4(HttpRequest request) throws Exception {
        /* X-Amz-Dateヘッダーの生成 */
        SimpleDateFormat xAmzDateFormatter	= new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
        xAmzDateFormatter.setTimeZone(Calendar.getInstance(TimeZone.getTimeZone("UTC"))); // Signerの有効期限はUTCで判定される
        String xAmzDate 		= xAmzDateFormatter.format(new Date()).trim();
        request.setHeader("X-Amz-Date", xAmzDate);

        /* Authorizationヘッダー文字列の生成 */
        /* 1. 正規リクエスト文字列(canonicalRequest)の生成 */
        String path 			= getPath(request);
        String xAmzContentSha   = getBodyHash(request);
        String canonicalRequest = "POST\n"
                            + path + "\n"
                            + "\n"
                            + "host:" + request.getFirstHeader("Host").getValue() + "\n"
                            + "x-amz-date:" + xAmzDate + "\n"
                            + "\n"
                            + "host;x-amz-date\n"
                            + xAmzContentSha;

        /* 2. 署名文字列(StringToSign)の生成 */
        String awsRegion = "ap-northeast-1" ;  // AWS APIリクエスト先のリージョン情報
        String awsNameSpace = "es" ;  // リクエストするAWSサービスの名前空間
        String StringToSign = "AWS4-HMAC-SHA256\n"
                            + xAmzDate + "\n"
                            + xAmzDate.substring(0, 8) + "/" + awsRegion 
                            + "/" + awsNameSpace +"/aws4_request\n"
                            + DigestUtils.sha256Hex(canonicalRequest);

        /* 3. 署名キー(SigningKey)の生成 */
        String awsSecretAccessKey = "AWSシークレットアクセスキー" ;
        // X-Amz-Date → リージョン → AWSサービス名前空間 → 固定文字(aws4_request) の順でハッシュ化していく
        String hashStr    = getHmacSha256ByStrKey("AWS4" + awsSecretAccessKey, xAmzDate.substring(0, 8));
        hashStr           = getHmacSha256ByHexKey(hashStr, awsRegion);
        hashStr           = getHmacSha256ByHexKey(hashStr, awsNameSpace);
        String SigningKey = getHmacSha256ByHexKey(hashStr, "aws4_request");

        /* 4. Authorizationヘッダー文字列の生成 */
        String awsAccessKeyId = "AWSアクセスキーID" ;
        String sig 			= getHmacSha256ByHexKey(SigningKey, StringToSign);
        String authorization = "AWS4-HMAC-SHA256 Credential="
                            + awsAccessKeyId
                            + "/" + xAmzDate.substring(0, 8)
                            + "/" + awsRegion
                            + "/" + awsNameSpace
                            + "/aws4_request,"
                            + "SignedHeaders=host;x-amz-date,"
                            + "Signature=" + sig;

        request.setHeader("Authorization", authorization);
    }

    /***  リクエストパスの取得 */
    private String getPath(HttpRequest req) throws Exception {
        String uri				= req.getRequestLine().getUri();

        // URLのクエリ文字列とのセパレータである「?」はpathに含めない
        if (uri.endsWith("?"))  uri = uri.substring(0, uri.length()-1);
        
        // URLエンコードも色々種類があるらしく、Amazonが指定したロジックでエンコードする
        return awsUriEncode(uri,true);
    }

    /*** リクスとボディのハッシュ取得 */
    private String getBodyHash(HttpRequest req) throws Exception{
        HttpEntityEnclosingRequest ereq = (HttpEntityEnclosingRequest) req;
        String body				= EntityUtils.toString(ereq.getEntity());
        return DigestUtils.sha256Hex(body);
    }

    /***
        * AWS指定スペックのURLエンコーダ
        * @param input
        * @param encodeSlash
        * @return
        * @throws UnsupportedEncodingException
        */
    private String awsUriEncode(CharSequence input, boolean encodeSlash) throws UnsupportedEncodingException {
        StringBuilder result = new StringBuilder();
        boolean queryIn = false;
        for (int i = 0; i < input.length(); i++) {
            char ch = input.charAt(i);
            if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '~' || ch == '.') {
                result.append(ch);
            } else if (ch == '/') {
                if (queryIn) result.append(encodeSlash ? "%2F" : ch);
                else result.append(ch);
            } else {
                if(!queryIn && ch=='?') {
                    queryIn = true;
                    result.append(ch);
                }else {
                    byte[] bytes = new String(new char[] {ch}).getBytes("UTF-8");
                    result.append("%" + Hex.encodeHexString(bytes,false));
                }
            }
        }
        return result.toString();
    }

    private String getHmacSha256ByStrKey(String strkey, String target) throws Exception {
        return getHmacSha256(strkey.getBytes(), target);
    }

    private String getHmacSha256ByHexKey(String hexkey, String target) throws Exception {
        return getHmacSha256(Hex.decodeHex(hexkey), target);
    }

    /***
        * target文字列をKeyを使いHMAC-SHA-256にハッシュ化する
        * @param target ハッシュ対象
        * @param key キー
        * @return Hex形式のハッシュ値
        */
    private String getHmacSha256(byte[] key, String target) throws Exception {
        final Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        return String.valueOf(Hex.encodeHex(mac.doFinal(target.getBytes()), true));
    }
}
```

# あとがき
 今回、私が実際に動作確認したのはElasticsearch APIのみですが、IAMの権限が正しく設定されていれば、他のAPIも同じ方法で利用できるかと思います。もし動作確認が取れたAWSサービスがあったら、コメントに書いていただけるとありがたいです。
 
お役に立ちましたら幸いです。