はじめに
AWS APIは、リクエストにAWS Signature V4という署名をつけることでIAM認証を利用できます。普通はAWS SDKを利用することでSignature V4の仕様をさほど意識する必要はないのですが、諸事情で、自前によるSignature V4実装をする機会がありましたので、メモを残します。
Java 11で実装しており、HTTPクライアントはApache HttpComponents Clientを使ってます。
コードは切り貼り、加工しているので、正しく動作するか若干怪しいです。
署名しない時のリクエスト
わかりやすくするため、署名しないときのリクエストサンプルを示します。 Elasticsearch APIで検索リクエストするサンプルです。このサンプルではPOSTメソッドを利用しているためクエリパラメータはHTTP bodyに書いています。
このリクエストにAWS Signature V4署名を施し、認証されたユーザからのみAPIを受けるように改修していきます。
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
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クライアント呼び出し部の修正
//...略...
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の値を求めるのに何段階か必要になります。
- 正規リクエスト文字列(canonicalRequest)の生成
- 署名文字列(StringToSign)の生成
- 署名キー(SigningKey)の生成
- Authorizationヘッダー文字列の生成
参考までに、canonicalRequestに含まれるヘッダー情報は、host,x-amz-dateが必須ですが、それ以外のヘッダー情報を追加しても良いみたいです。
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));
}
まとめ
最終的にまとめると下記のようになりました。
リクエスト毎にフォーマッターを生成したり、定数が埋め込みになっていたりでアレですが、そこら辺はよしなに直してください。
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サービスがあったら、コメントに書いていただけるとありがたいです。
お役に立ちましたら幸いです。