LoginSignup
1
1

More than 1 year has passed since last update.

aws-sdk を使わずにS3からデータを取得する

Last updated at Posted at 2021-10-06

はじめに

タイトルの通り、 aws-sdk を使わずに s3 からのデータ取得するやり方をまとめます。具体的には、AWS Signature Version 4 による署名を含む authorization ヘッダーの作成手順を示したものになります。

本記事で紹介しているコードは node.jsで実装しています。できるだけ、ビルドインモジュールのみを使って実装しましたが、http リクエストだけめんどくさかったので axios を使っています。

コードだけ知りたい方はこちらをどうぞ。

準備

iam ユーザの作成

AmazonS3ReadOnlyAccess ポリシーのアタッチされたユーザーを作成してください。この時に発行される、access_key_idsecret_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

  1. canonical request の作成
  2. canonical request を含む署名に必要な文字列の作成
  3. 署名の作成
  4. 署名を含む authorization ヘッダーの作成
  5. 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) {
    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進数で表したもの

コード

完成コードの中の、下記の部分が 署名に必要な文字列の作成をしています。

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 からデータを取得することができます!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1