はじめに
タイトルの通り、 aws-sdk を使わずに s3 からのデータ取得するやり方をまとめます。具体的には、AWS Signature Version 4 による署名を含む authorization ヘッダーの作成手順を示したものになります。
本記事で紹介しているコードは node.jsで実装しています。できるだけ、ビルドインモジュールのみを使って実装しましたが、http リクエストだけめんどくさかったので axios を使っています。
コードだけ知りたい方はこちらをどうぞ。
準備
iam ユーザの作成
AmazonS3ReadOnlyAccess
ポリシーのアタッチされたユーザーを作成してください。この時に発行される、access_key_id
と secret_access_key
をメモしておいてください。後で使います。
バケットを作成
東京リージョン(ap-northeast-1)にバケットを作成してください。この時のバケット名をメモしておいて下さい。後で使います。
バケットにファイルを保存
先ほど作成したバケットにファイルを保存します。ファイル名やディレクトリ名は何でも構いません。本記事では、以下の内容のテキストファイルをバケット直下に test.txt という名前で保存します。
s3 fetchObject test
環境変数の設定
実行環境の環境変数を設定します。先ほどメモしておいた値を使います。
AWS_ACCESS_KEY=<access_key_id>
AWS_SECRET_KEY=<secret_access_key>
BUCKET=<bucket名>
REGION=ap-northeast-1
axiosのインストール
npm install axios
完成コード
const crypto = require("crypto")
const axios = require("axios").default
const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY
const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY
const BUCKET = process.env.BUCKET
const REGION = process.env.REGION
const AWS_SERVICE = "s3"
const HOST = `${AWS_SERVICE}.${REGION}.amazonaws.com`
const HASH_ALGORHYZM = "SHA256"
class S3Service {
constructor() {
this.requestDate = new Date()
}
async fetchObject(params) {
this.setRequestDate()
const url = this.getUrl(params.filepath)
const headers = this.getHeader(params)
const res = await axios.get(url, { headers })
return res
}
createAuthorization(params) {
const credential = this.getCredential()
const signedHeaders = this.getSignedHeaders(params.headers)
const signature = this.createSignature(params)
return `AWS4-HMAC-SHA256 Credential=${credential},SignedHeaders=${signedHeaders},Signature=${signature}`
}
createSignature(params) {
const formattedDate = this.formatRequestDate("YYYYMMDD")
const dateKey = this.hmac("AWS4" + AWS_SECRET_KEY, formattedDate)
const dateRegionKey = this.hmac(dateKey, REGION)
const dateRegionServiceKey = this.hmac(dateRegionKey, AWS_SERVICE)
const signingKey = this.hmac(dateRegionServiceKey, "aws4_request")
const sign = this.createStringToSign(params)
return this.hmacHex(signingKey, sign)
}
createStringToSign(params) {
const formattedDate = this.formatRequestDate("ISO8601")
const scope = this.getScope()
const canonicalRequest = this.createCanonicalRequest(params)
const signStrings = [
"AWS4-HMAC-SHA256",
formattedDate,
scope,
this.hashHex(canonicalRequest)
]
return signStrings.join("\n")
}
createCanonicalRequest(params) {
const requests = []
const hasquery = params.query && typeof params.query === "object"
const method = this.getMethod(params.method)
requests.push(method)
const canonicalUri = this.getCanonicalUri(params.filepath, hasquery)
requests.push(canonicalUri)
const canonicalQueryString = this.getCanonicalQueryString(params.query, hasquery)
requests.push(canonicalQueryString)
const canonicalHeaders = this.getCanonicalHeaders(params.headers)
requests.push(canonicalHeaders)
const signedHeaders = this.getSignedHeaders(params.headers)
requests.push(signedHeaders)
const hashedPayload = this.getHashedPayload(params.payload)
requests.push(hashedPayload)
return requests.join("\n")
}
getScope() {
const formattedDate = this.formatRequestDate("YYYYMMDD")
return `${formattedDate}/${REGION}/${AWS_SERVICE}/aws4_request`
}
getUrl(filepath) {
return `https://${HOST}/${BUCKET}/${filepath}`
}
getEssentialHeaders(params) {
return {
host: HOST,
"x-amz-content-sha256": this.hashHex(params.payload),
"x-amz-date": this.formatRequestDate("ISO8601")
}
}
getHeader(params) {
const essentailHeaders = this.getEssentialHeaders(params)
params.headers = Object.assign(essentailHeaders, params.headers)
const authorization = this.createAuthorization(params)
return Object.assign({ authorization }, params.headers)
}
getCredential() {
const scope = this.getScope()
return `${AWS_ACCESS_KEY}/${scope}`
}
getMethod(method) {
return method.toUpperCase()
}
getCanonicalUri(filepath, hasquery) {
const uri = `/${BUCKET}/${filepath}`
return hasquery ? uri + "?" : uri
}
getCanonicalQueryString(query, hasquery) {
if(!hasquery) return ""
const queryStrings = this.objectToString(query, "=")
return encodeURI(queryStrings.join("&"))
}
getCanonicalHeaders(header) {
const headerStrings = this.objectToString(header, ":")
return headerStrings.join("\n") + "\n"
}
getSignedHeaders(header) {
return Object.keys(header).map(k => k.toLowerCase()).sort().join(";")
}
getHashedPayload(payload) {
return this.hashHex(payload)
}
setRequestDate() {
this.requestDate = new Date()
}
objectToString(query, sep) {
const _query = {}
Object.keys(query).forEach(k => {
_query[k.toLowerCase()] = query[k]
})
const queryStrings = []
Object.keys(_query).sort().forEach(k => {
queryStrings.push(k + sep + _query[k])
})
return queryStrings
}
_hash(data) {
return crypto.createHash(HASH_ALGORHYZM).update(data)
}
hashHex(data) {
return this._hash(data).digest("hex")
}
_hmac(secretKey, data) {
return crypto.createHmac(HASH_ALGORHYZM, secretKey).update(data)
}
hmac(secretKey, data) {
return this._hmac(secretKey, data).digest()
}
hmacHex(secretKey, data) {
return this._hmac(secretKey, data).digest("hex")
}
formatRequestDate(type) {
const Y = this.requestDate.getUTCFullYear()
const M = this.requestDate.getUTCMonth() + 1
const D = this.requestDate.getUTCDate()
const h = this.requestDate.getUTCHours()
const m = this.requestDate.getUTCMinutes()
const s = this.requestDate.getUTCSeconds()
const YYYY = Y.toString().padStart(4, "0")
const MM = M.toString().padStart(2, "0")
const DD = D.toString().padStart(2, "0")
const hh = h.toString().padStart(2, "0")
const mm = m.toString().padStart(2, "0")
const ss = s.toString().padStart(2, "0")
switch(type) {
case "YYYYMMDD":
return `${YYYY}${MM}${DD}`
case "ISO8601":
return `${YYYY}${MM}${DD}T${hh}${mm}${ss}Z`
}
}
}
コードの実行・結果
上記の s3 からデータを取得するためのクラスをインポートして使います。
const S3Service = require("./S3Service").default
async function fetchObject() {
try {
const s3 = new S3Service()
const res = await s3.fetchObject({
method: "GET",
filepath: "test.txt",
payload: ""
})
console.log(res.data)
} catch (e) {
console.log(e)
}
}
fetchObject()
❯ node fetchObject.js
s3 fetchObject test
無事 s3 からデータの取得ができていることが確認できました!
AWS Signature Version 4 による署名を含むauthorization ヘッダーの作成手順
ここからは上記で作成したコードの説明となります。
概要
下記の記事を node.js で実装したものになります。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-header-based-auth.html
- canonical request の作成
- canonical request を含む署名に必要な文字列の作成
- 署名の作成
- 署名を含む authorization ヘッダーの作成
- authorization ヘッダーを設定して http リクエスト
canonical request の作成
フォーマット
canonical request のフォーマットは下記のような構成になっております。
<HTTPMethod>\n
<CanonicalURI>\n
<CanonicalQueryString>\n
<CanonicalHeaders>\n
<SignedHeaders>\n
<HashedPayload>
それでは詳細を見ていきます。
HTTPMethod
uppercase の http メソッドになります。
例)GET, PUT, POST, DELETE ...
CanonicalURI
取得したい s3 オブジェクトまでのURIエンコード(パーセントエンコード)されたホスト部以降の絶対パス。クエリーストリングがある場合は ?
まで入れる。?
以降は CanonicalQueryString で指定。
例)http://s3.amazonaws.com/examplebucket/myphoto.jpg?hoge=x の場合
/examplebucket/myphoto.jpg?
CanonicalQueryString
URIエンコード(パーセントエンコード)されたクエリーストリング。
例)http://s3.amazonaws.com/examplebucket?prefix=somePrefix&marker=someMarker&max-keys=20 の場合
encodeURI("marker") + "=" + encodeURI("someMarker") + "&" +
encodeURI("max-keys")+ "=" + encodeURI("20") + "&" +
encodeURI("prefix") + "=" + encodeURI("somePrefix")
marker=someMarker&max-keys=20&prefix=somePrefix
値を持っていないクエリーストリングも指定できる
例)http://s3.amazonaws.com/examplebucket?acl の場合
encodeURI("acl") + "=" + ""
acl=
CanonicalHeaders
リクエストヘッダーのリスト。以下の条件を満たす必要がある。
-
\n
で区切る - ヘッダー名は lowwercase
- ヘッダー名でアルファベット順にソート
- 必須要素
- host
- Content-Type (request header で指定している場合)
- request header に指定している x-amz-* header
- x-amz-content-sha256 は 全ての AWS Signature Version 4 requestsで必要
- payload (body) を hash 化した値を入れる
- 値がない場合は空文字を hash 化
- x-amz-content-sha256 は 全ての AWS Signature Version 4 requestsで必要
※ 最後にも \n
が必要なので注意
例) header が以下の場合
Host:s3.amazonaws.com
X-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b785
2b855
X-amz-date:20130708T220855Z
"Host".toLowerCase() + ":" + host.trim() + "\n" +
"X-amz-content-sha256".toLowerCase() + ":" + xAmzContentSha256.trim() + "\n" +
"X-amz-date".toLowerCase() + ":" + xAmzDate.trim() + "\n"
host:s3.amazonaws.com
x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b785
2b855
x-amz-date:20130708T220855Z // 末尾にも改行が入っている
SignedHeaders
CanonicalHeaders のヘッダー名のリスト。以下の条件を満たす必要がある。
-
;
で区切る - ヘッダー名は lowwercase
- ヘッダー名でアルファベット順にソート
例)host, x-amz-content-sha256, x-amz-date の場合
host;x-amz-content-sha256;x-amz-date
HashedPayload
request payload(body) を sha256 で hash 化した値を16進数で表した値
例)crypto モジュールを使った場合
crypto
.createHash("SHA256")
.update(payload)
.toString(16)
コード
完成コードの中の、下記の部分が canonical request の作成をしています。
const crypto = require("crypto")
const BUCKET = process.env.BUCKET
const HASH_ALGORHYZM = "SHA256"
class S3Service {
(中略)
createCanonicalRequest(params) {
const requests = []
const hasquery = params.query && typeof params.query === "object"
const method = this.getMethod(params.method)
requests.push(method)
const canonicalUri = this.getCanonicalUri(params.filepath, hasquery)
requests.push(canonicalUri)
const canonicalQueryString = this.getCanonicalQueryString(params.query, hasquery)
requests.push(canonicalQueryString)
const canonicalHeaders = this.getCanonicalHeaders(params.headers)
requests.push(canonicalHeaders)
const signedHeaders = this.getSignedHeaders(params.headers)
requests.push(signedHeaders)
const hashedPayload = this.getHashedPayload(params.payload)
requests.push(hashedPayload)
return requests.join("\n")
}
(中略)
getMethod(method) {
return method.toUpperCase()
}
getCanonicalUri(filepath, hasquery) {
const uri = `/${BUCKET}/${filepath}`
return hasquery ? uri + "?" : uri
}
getCanonicalQueryString(query, hasquery) {
if(!hasquery) return ""
const queryStrings = this.objectToString(query, "=")
return encodeURI(queryStrings.join("&"))
}
getCanonicalHeaders(header) {
const headerStrings = this.objectToString(header, ":")
return headerStrings.join("\n") + "\n"
}
getSignedHeaders(header) {
return Object.keys(header).map(k => k.toLowerCase()).sort().join(";")
}
getHashedPayload(payload) {
return this.hashHex(payload)
}
(中略)
objectToString(query, sep) {
const _query = {}
Object.keys(query).forEach(k => {
_query[k.toLowerCase()] = query[k]
})
const queryStrings = []
Object.keys(_query).sort().forEach(k => {
queryStrings.push(k + sep + _query[k])
})
return queryStrings
}
_hash(data) {
return crypto.createHash(HASH_ALGORHYZM).update(data)
}
hashHex(data) {
return this._hash(data).digest("hex")
}
}
署名に必要な文字列の作成
フォーマット
StringToSign =
Algorithm + \n +
RequestDateTime + \n +
CredentialScope + \n +
HashedCanonicalRequest
Algorithm
AWS Signature Version 4 では AWS4-HMAC-SHA256
を使用します。
RequestDateTime
ISO8601に準拠したフォーマットを用います。(-
がないもの)
https://ja.wikipedia.org/wiki/ISO_8601
"AWS4-HMAC-SHA256" + "\n" +
timeStampISO8601Format + "\n" +
<Scope> + "\n" +
Hex(SHA256Hash(<CanonicalRequest>))
Scope
スコープは以下のようなフォーマットになっている。
date.Format(<YYYYMMDD>) + "/" + <region> + "/" + <service> + "/aws4_request"
例)2021/10/05、東京リージョンの場合
20211005/ap-northeast-1/s3/aws4_request
Hex(SHA256Hash(<CanonicalRequest>))
canonical request を SHA256 でハッシュ化し、16進数で表したもの
コード
完成コードの中の、下記の部分が 署名に必要な文字列の作成をしています。
class S3Service {
constructor() {
this.requestDate = new Date()
}
(中略)
createStringToSign(params) {
const formattedDate = this.formatRequestDate("ISO8601")
const scope = this.getScope()
const canonicalRequest = this.createCanonicalRequest(params)
const signStrings = [
"AWS4-HMAC-SHA256",
formattedDate,
scope,
this.hashHex(canonicalRequest)
]
return signStrings.join("\n")
}
createCanonicalRequest(params) {
(中略)
}
(中略)
hashHex(data) {
(中略)
}
formatRequestDate(type) {
const Y = this.requestDate.getUTCFullYear()
const M = this.requestDate.getUTCMonth() + 1
const D = this.requestDate.getUTCDate()
const h = this.requestDate.getUTCHours()
const m = this.requestDate.getUTCMinutes()
const s = this.requestDate.getUTCSeconds()
const YYYY = Y.toString().padStart(4, "0")
const MM = M.toString().padStart(2, "0")
const DD = D.toString().padStart(2, "0")
const hh = h.toString().padStart(2, "0")
const mm = m.toString().padStart(2, "0")
const ss = s.toString().padStart(2, "0")
switch(type) {
case "YYYYMMDD":
return `${YYYY}${MM}${DD}`
case "ISO8601":
return `${YYYY}${MM}${DD}T${hh}${mm}${ss}Z`
}
}
}
署名の作成
フォーマット
以下のようなフォーマットになっています。最終的に Signature
を作成します。
DateKey = HMAC-SHA256("AWS4"+"<SecretAccessKey>", "<YYYYMMDD>")
DateRegionKey = HMAC-SHA256(<DateKey>, "<aws-region>")
DateRegionServiceKey = HMAC-SHA256(<DateRegionKey>, "<aws-service>")
SigningKey = HMAC-SHA256(<DateRegionServiceKey>, "aws4_request")
Siganture = HMAC-SHA256(SigningKey, StringToSign)
HMAC-SHA256 は SHA256 をハッシュ関数に使用して HMAC を計算しています。簡単に HMAC の説明をすると、HMAC はメッセージ認証コードの計算方法の一つで、計算にハッシュ関数と共通鍵を持ちます。
HMAC-SHA256 をコードで表すと以下のようになります。
function hmac(key, data) {
return crypto.createHmac("SHA256", key).update(data).digest()
}
コード
完成コードの中の、下記の部分が 署名の作成をしています
const crypto = require("crypto")
const axios = require("axios").default
const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY
const BUCKET = process.env.BUCKET
const REGION = process.env.REGION
const AWS_SERVICE = "s3"
const HASH_ALGORHYZM = "SHA256"
class S3Service {
(中略)
createSignature(params) {
const formattedDate = this.formatRequestDate("YYYYMMDD")
const dateKey = this.hmac("AWS4" + AWS_SECRET_KEY, formattedDate)
const dateRegionKey = this.hmac(dateKey, REGION)
const dateRegionServiceKey = this.hmac(dateRegionKey, AWS_SERVICE)
const signingKey = this.hmac(dateRegionServiceKey, "aws4_request")
const sign = this.createStringToSign(params)
return this.hmacHex(signingKey, sign)
}
createStringToSign(params) {
(中略)
}
(中略)
_hmac(secretKey, data) {
return crypto.createHmac(HASH_ALGORHYZM, secretKey).update(data)
}
hmac(secretKey, data) {
return this._hmac(secretKey, data).digest()
}
hmacHex(secretKey, data) {
return this._hmac(secretKey, data).digest("hex")
}
formatRequestDate(type) {
(中略)
}
}
署名を含む authorization ヘッダーの作成
上記で作成したものを authorization ヘッダーに設定すれば s3 からデータを取得することができます。
フォーマット
Algorithm Credential=<Credential>,SignedHeaders=<SignedHeaders>,Signature=<Signature>
Algorithm
こちらと同様、AWS4-HMAC-SHA256
となります。
Credential
以下のようなフォーマットになっています。
AWS_ACCESS_KEY は aws の iam ユーザーのアクセスキーになっています。
Scope はこちらで説明した通りです。
<AWS_ACCESS_KEY>/<Scope>
// 例
AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request
SignedHeaders
SignedHeaders はこちらで説明した通りです。
Signature
Signature はこちらで説明したものを使います。
コード
完成コードの中の、下記の部分が署名を含む authorization ヘッダーの作成をしています。
const crypto = require("crypto")
const axios = require("axios").default
const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY
const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY
const BUCKET = process.env.BUCKET
const REGION = process.env.REGION
const AWS_SERVICE = "s3"
const HOST = `${AWS_SERVICE}.${REGION}.amazonaws.com`
const ALLOWED_HTTP_METHOD = ["GET", "PUT", "POST", "DELETE"]
const HASH_ALGORHYZM = "SHA256"
class S3Service {
(中略)
createAuthorization(params) {
const credential = this.getCredential()
const signedHeaders = this.getSignedHeaders(params.headers)
const signature = this.createSignature(params)
return `AWS4-HMAC-SHA256 Credential=${credential},SignedHeaders=${signedHeaders},Signature=${signature}`
}
createSignature(params) {
(中略)
}
(中略)
getScope() {
const formattedDate = this.formatRequestDate("YYYYMMDD")
return `${formattedDate}/${REGION}/${AWS_SERVICE}/aws4_request`
}
(中略)
getCredential() {
const scope = this.getScope()
return `${AWS_ACCESS_KEY}/${scope}`
}
getSignedHeaders(header) {
return Object.keys(header).map(k => k.toLowerCase()).sort().join(";")
}
}
authorization ヘッダーを設定して http リクエスト
やっていることは以下の通りです。
- setRequestDate で リクエストに使う共通の date の値を生成
- url を取得
- ヘッダーを設定
- CanonicalHeaders で設定したもの
- authorization で作成したもの
- http リクエスト
const axios = require("axios").default
const BUCKET = process.env.BUCKET
const AWS_SERVICE = "s3"
const HOST = `${AWS_SERVICE}.${REGION}.amazonaws.com`
class S3Service {
constructor() {
this.requestDate = new Date()
}
async fetchObject(params) {
this.setRequestDate()
const url = this.getUrl(params.filepath)
const headers = this.getHeader(params)
const res = await axios.get(url, { headers })
return res
}
createAuthorization(params) {
(中略)
}
(中略)
getUrl(filepath) {
return `https://${HOST}/${BUCKET}/${filepath}`
}
getEssentialHeaders(params) {
return {
host: HOST,
"x-amz-content-sha256": this.hashHex(params.payload),
"x-amz-date": this.formatRequestDate("ISO8601")
}
}
getHeader(params) {
const essentailHeaders = this.getEssentialHeaders(params)
params.headers = Object.assign(essentailHeaders, params.headers)
const authorization = this.createAuthorization(params)
return Object.assign({ authorization }, params.headers)
}
setRequestDate() {
this.requestDate = new Date()
}
}
これで、AWS Signature Version 4 による署名を用いて s3 からデータを取得することができます!