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

# はじめに

タイトルの通り、 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 という名前で保存します。

```plaintext:test.txt
s3 fetchObject test
```

## 環境変数の設定

実行環境の環境変数を設定します。先ほどメモしておいた値を使います。

```bash
AWS_ACCESS_KEY=<access_key_id>
AWS_SECRET_KEY=<secret_access_key>
BUCKET=<bucket名>
REGION=ap-northeast-1
```

## axiosのインストール

```bash
npm install axios
```

# 完成コード

```js: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 からデータを取得するためのクラスをインポートして使います。

```js: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()
```

```bash: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](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 のフォーマットは下記のような構成になっております。

```xml
<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 の場合

```plaintext:値
/examplebucket/myphoto.jpg?
```

### CanonicalQueryString

URIエンコード（パーセントエンコード）されたクエリーストリング。

例）http://s3.amazonaws.com/examplebucket?prefix=somePrefix&marker=someMarker&max-keys=20 の場合

```jsx:code
encodeURI("marker")  + "=" + encodeURI("someMarker") + "&" +
encodeURI("max-keys")+ "=" + encodeURI("20")         + "&" +
encodeURI("prefix")  + "=" + encodeURI("somePrefix")
```
```plaintext:値
marker=someMarker&max-keys=20&prefix=somePrefix
```

値を持っていないクエリーストリングも指定できる

例）http://s3.amazonaws.com/examplebucket?acl の場合

```jsx:code
encodeURI("acl") + "=" + ""
```
```plaintext:値
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



```jsx:code
"Host".toLowerCase()                 + ":" + host.trim()              + "\n" + 
"X-amz-content-sha256".toLowerCase() + ":" + xAmzContentSha256.trim() + "\n" + 
"X-amz-date".toLowerCase()           + ":" + xAmzDate.trim()          + "\n"
```
```plaintext:値
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 の場合

```plaintext:値
host;x-amz-content-sha256;x-amz-date
```

### HashedPayload

request payload(body) を sha256 で hash 化した値を16進数で表した値

例）crypto モジュールを使った場合

```jsx:code
crypto
	.createHash("SHA256")
	.update(payload)
	.toString(16) 
```

### コード

[完成コード](#完成コード)の中の、下記の部分が canonical request の作成をしています。

```jsx
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")
  }
}
```

## 署名に必要な文字列の作成

### フォーマット

```jsx
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](https://ja.wikipedia.org/wiki/ISO_8601)

```plaintext
"AWS4-HMAC-SHA256" + "\n" +
timeStampISO8601Format + "\n" +
<Scope> + "\n" +
Hex(SHA256Hash(<CanonicalRequest>))
```

### Scope

スコープは以下のようなフォーマットになっている。

```jsx

date.Format(<YYYYMMDD>) + "/" + <region> + "/" + <service> + "/aws4_request"
```

例）2021/10/05、東京リージョンの場合

```plaintext
20211005/ap-northeast-1/s3/aws4_request
```

### Hex(SHA256Hash(＜CanonicalRequest＞))

canonical request を SHA256 でハッシュ化し、16進数で表したもの

### コード

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

```jsx: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` を作成します。

```plaintext
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 をコードで表すと以下のようになります。

```jsx:code
function hmac(key, data) {
  return crypto.createHmac("SHA256", key).update(data).digest()
}
```

### コード

[完成コード](#完成コード)の中の、下記の部分が 署名の作成をしています

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

### フォーマット

```plaintext
Algorithm Credential=<Credential>,SignedHeaders=<SignedHeaders>,Signature=<Signature>
```

### Algorithm

[こちら](#algorithm)と同様、`AWS4-HMAC-SHA256` となります。

### Credential

以下のようなフォーマットになっています。

AWS_ACCESS_KEY は aws の iam ユーザーのアクセスキーになっています。

Scope はこちらで説明した通りです。

```jsx
<AWS_ACCESS_KEY>/<Scope>

// 例
AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request
```

### SignedHeaders

SignedHeaders は[こちら](#signedheaders)で説明した通りです。

### Signature

Signature は[こちら](#署名の作成)で説明したものを使います。

### コード

完成コードの中の、下記の部分が署名を含む authorization ヘッダーの作成をしています。

```jsx: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 リクエスト

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