はじめに
タイトルの通り、 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 という名前で保存します。
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
完成コード
S3Service.js
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 からデータを取得するためのクラスをインポートして使います。
fetchObject.js
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()
console
❯ 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 の場合
code
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 の場合
code
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 化
※ 最後にも \n が必要なので注意
例) header が以下の場合
Host:s3.amazonaws.com
X-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b785
2b855
X-amz-date:20130708T220855Z
code
"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 モジュールを使った場合
code
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) {
    const [isValid, msg] = this.validateMethod(method)
    if(!isValid) throw Error(msg)
    return method.toUpperCase()
  }
  getCanonicalUri(filepath, hasquery) {
    const [isValid, msg] = this.validateFilepath(filepath)
    if(!isValid) throw Error(msg)
    const uri = `/${BUCKET}/${filepath}`
    return hasquery ? uri + "?" : uri
  }
  getCanonicalQueryString(query, hasquery) {
    if(!hasquery) return ""
    const [isValid, msg] = this.validateQuery(query)
    if(!isValid) throw Error(msg)
    const queryStrings = this.objectToString(query, "=")
    return encodeURI(queryStrings.join("&"))
  } 
  getCanonicalHeaders(header) {  
    const [isValid, msg] = this.validateHeader(header)
    if(!isValid) throw Error(msg)
    const headerStrings = this.objectToString(header, ":")
    return headerStrings.join("\n") + "\n"
  }
  getSignedHeaders(header) {  
    const [isValid, msg] = this.validateHeader(header)
    if(!isValid) throw Error(msg)
    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())
canonical request を SHA256 でハッシュ化し、16進数で表したもの
コード
完成コードの中の、下記の部分が 署名に必要な文字列の作成をしています。
S3Service.js
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 をコードで表すと以下のようになります。
code
function hmac(key, data) {
  return crypto.createHmac("SHA256", key).update(data).digest()
}
コード
完成コードの中の、下記の部分が 署名の作成をしています
S3Service
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 ヘッダーの作成をしています。
S3Service
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 からデータを取得することができます!
                       
                           
                       
                     ↧