(※この記事は 別媒体に投稿した記事 のバックアップです。 canonical も設定しています)
2022-08-15
この記事は TypeScript を使い AWS Signature V4 をスクラッチで実装してみた記録です。
目的としては、AWS の API リクエストをする時の認証についてよく知らなかったので、勉強がてら 実装してみようと思った次第です。
基本的に 以下の AWS 公式ドキュメントにある内容を実装しただけです。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4_signing.html
この記事で紹介するサンプルコードは、以下の Gist からの抜粋です。
コード全体を見たい場合は、こちらの Gist を見てもらうと良いと思います。
https://gist.github.com/mryhryki/58a1ad77a5e3f3ff14c23324c7b346af
いくつかの API でリクエスト可能であることは確認しましたが、すべての場合において正しく動作するかは不明です。
この記事内および上記の Gist のコードは自由に使っていただいて構いませんが、自己責任でご使用ください。
プロダクション環境で使う時は AWS SDK for JavaScript を使うことを強くおすすめします
今回は Deno で動かせるコードにしています。
単純に TypeScript を手軽に動かしやすいから、が理由です。
セットアップ方法は、以下のリンクから確認してください。
https://deno.land/manual/getting_started/installation
また、特別なライブラリは使用していないので、以下のように修正すれば Node.js でも実行できるようになると思います。(動作は未確認です)
- const getOptionalEnv = (key: string): string | null | undefined => Deno.env.get(key);
+ const getOptionalEnv = (key: string): string | null | undefined => process.env[key];
node:crypto
モジュールの中身をグローバルにセットしておくNode.js には crypto がグローバルに存在しないので、以下のようにセットしておけば動くはずです。
+ import crypto from "node:crypto";
+ globalThis.crypto = crypto.webcrypto;
今回は分かりやすくするために、バイナリデータの扱いは Uint8Array
に統一して実装しています。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array
いくつか変換に使用するための関数を実装しているので紹介します。
const textToBin = (text: string): Uint8Array =>
new TextEncoder().encode(text);
const binToHexText = (buf: Uint8Array): string =>
[...buf].map((b): string => b.toString(16).padStart(2, "0")).join("");
const digestSha256 = async (data: Uint8Array): Promise<Uint8Array> =>
new Uint8Array(await crypto.subtle.digest("SHA-256", data));
const hmacSha256 = async (key: Uint8Array, message: Uint8Array): Promise<Uint8Array> => {
const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, true, ["sign"]);
const signedData = await crypto.subtle.sign("HMAC", cryptoKey, message);
return new Uint8Array(signedData);
};
https://stackoverflow.com/a/56416039 を参考にしました。
既に書いたとおり、コードは Gist に置いています。
https://gist.github.com/mryhryki/58a1ad77a5e3f3ff14c23324c7b346af
以下はこのコードのポイントを解説しています。
コード全体を見ながら見たほうが、人によっては理解しやすいかもしれません。
今回は Request オブジェクトを受け取り、生成した署名を設定した Request オブジェクトを返すような関数で実装します。
const signRequest = async (request: Request, params: AwsParams): Promise<Request> => {
// ...
return signedRequest;
}
また、第2引数に AWS のパラメーターを設定するようにしています。
これは単にテストがしやすいからというだけの理由です。
内部で何度か日時、日付の文字列が必要になります。
標準の Date
オブジェクトで十分そうだったので、以下のようなコードで取得するようにしました。
const dateTimeText = new Date().toISOString().replace(/\.[0-9]{3}/, "").replace(/[-:]/g, "");
const dateText = dateTimeText.substring(0, 8);
console.log(JSON.stringify({ dateTimeText, dateText }));
// => {"dateTimeText":"20220814T084035Z","dateText":"20220814"}
HTTP リクエストの情報から SHA-256 文字列を生成します。
以下の内容に沿って実装します。
https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
まず、URL に含まれるクエリパラメーターをキー名の昇順で並べ替えて &
で結合した文字列を生成します。
const canonicalQueryString = Array.from(url.searchParams.entries())
.map(([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
.sort()
.join("&");
ヘッダーの小文字に変換した名前と値を整形し、 :
で結合し、名前の昇順に並び替えて改行文字で結合した文字列を生成します。
全てのヘッダーの内容を入れる必要はないですが、最低 Host
ヘッダーを含める必要があるようです。
const canonicalHeaders: string = Array.from(signedHeaders.entries())
.map(([key, val]) => `${key.toLowerCase().trim().replace(/ +/g, " ")}:${val.trim().replace(/ +/g, " ")}\n`)
.sort()
.join("");
ヘッダーの名前のみを小文字に変換し、名前の昇順に並び替えて ;
で結合した文字列を生成します。
const signedHeadersText: string = Array.from(signedHeaders.entries())
.map(([key]) => key.toLowerCase())
.sort()
.join(";");
リクエストボディの内容から SHA-256 を算出します。
const hashedPayload: string = binToHexText(await digestSha256(new Uint8Array(await request.clone().arrayBuffer())));
リクエストメソッドとパス、ここまでで算出した内容を改行文字で結合します。
const canonicalRequest: string = [
request.method,
url.pathname,
canonicalQueryString,
canonicalHeaders,
signedHeadersText,
hashedPayload,
].join("\n");
その文字列の SHA-256 を算出すれば完了です。
const hashedCanonicalRequest: string = binToHexText(await digestSha256(textToBin(canonicalRequest)));
署名アルゴリズム、日付、認証スコープ、正規リクエストの情報を改行文字で結合します。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-create-string-to-sign.html
特に説明するようなことはない、シンプルな処理です。
const stringToSign: string = [
"AWS4-HMAC-SHA256",
dateTimeText,
`${dateText}/${awsRegion}/${awsService}/aws4_request`,
hashedCanonicalRequest,
].join("\n");
HMACとシークレットアクセスキーを使って、リクエストデータを署名します。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-calculate-signature.html
AWSシークレットアクセスキー、日付、リージョン、サービスの情報を使って、HMAC-SHA256 を計算します。
const kDate = await hmacSha256(textToBin(`AWS4${awsSecretAccessKey}`), textToBin(dateText));
const kRegion = await hmacSha256(kDate, textToBin(awsRegion));
const kService = await hmacSha256(kRegion, textToBin(awsService));
const kSigning = await hmacSha256(kService, textToBin("aws4_request"));
const signature = binToHexText(await hmacSha256(kSigning, textToBin(stringToSign)));
これで署名文字列が生成されます。
文字列をまとめて計算するのではなく、何度も計算結果に対して HMAC-SHA256 を計算するのは、AWSシークレットアクセスキーをなるべく特定困難にしたいとかなんですかね。(単なる推測です)
リクエストヘッダーに署名文字列などを追加します。
(クエリパラメーターに設定することも可能ですが、本記事では対象外とします)
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-add-signature-to-request.html
認証情報の範囲など、いくつかの文字列を /
で結合します。
const credential = [awsAccessKeyId, dateText, awsRegion, awsService, "aws4_request"].join("/");
生成した署名などの情報を、指定のフォーマットで Authorization
ヘッダーにセットすれば署名の完了です。
const authorization = `${AwsSignatureAlgorithm} Credential=${credential}, SignedHeaders=${signedHeadersText}, Signature=${signature}`;
signedRequest.headers.set("Authorization", authorization);
実装ができたら、実際に API リクエストを試してみます。
今回は STS の GetCallerIdentity へリクエストしてみました。
https://docs.aws.amazon.com/ja_jp/STS/latest/APIReference/API_GetCallerIdentity.html
ドキュメントに従ってリクエストを組み立てて、実装した署名関数 (signRequest
) にわたすと署名ができます。
const awsRegion = "ap-northeast-1";
const awsService = "sts";
const signedRequest = await signRequest(
new Request(`https://${awsService}.${awsRegion}.amazonaws.com/`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Accept-Encoding": "identity",
"Accept": "application/json",
},
body: "Action=GetCallerIdentity&Version=2011-06-15",
}),
{
awsRegion,
awsService,
awsAccessKeyId: getRequiredEnv("AWS_ACCESS_KEY_ID"),
awsSecretAccessKey: getRequiredEnv("AWS_SECRET_ACCESS_KEY"),
awsSessionToken: getOptionalEnv("AWS_SESSION_TOKEN"),
},
);
署名さえ付与できれば、あとは単なる HTTP リクエストなので、普通に fetch
で取得できます。
const response = await fetch(signedRequest);
レスポンスからデータを取り出して表示されればOKです。
console.log(JSON.stringify(await response.json(), null, 2));
ちなみに CLI からも簡単に呼び出せます。
同じデータが取得できていれば成功です。
$ aws sts get-caller-identity
読み解いていくと、要素は多いもののやっていることは意外とシンプルでした。
しかし、実装してみたら単純なミス (typo や渡すべき値が違うなど) で結構引っかかり、またエラーメッセージから何が間違いなのか分かりづらいことも多かったです。
(認証に関わる部分なので当然ではあります)
あとは、認証に関わる部分を実装してみることで、使用する要素や手法からどのように不正なリクエストから守っているかが分かるのも面白かったです。
最後に大事なことなので何度も書きますが、プロダクション環境で使う時は AWS SDK for JavaScript を使うことを強くおすすめします。