Google Apps ScriptからAWSにアクセスするための署名の実装

| 12 min read
Author: shigeki-shoji shigeki-shojiの画像

こんにちは、庄司です。

Google FormsGoogle Sheets 等の Google アプリケーションを活用していて、例えば Google Sheets に入力された特定の範囲のデータを AWS の S3 にアップロードしたいと思いました。

単純に考えると AWS SDK for JavaScript の利用が真っ先に思い浮かびますが、残念ながらこれは使用できません。Google Apps Script の実行環境は Node.js とは異なる独自のものだからです。

この記事では、Google Apps Script 環境でも動作する npm モジュール を利用して S3 にファイルをアップロード (PutObject) できるようにするコード作成の方法を説明します。

Signature Version 4

#

AWS API との通信では、Signature Version 4 を使った署名によって正当性を検証します。

この署名のみを行う、@aws-sdk/signature-v4 というモジュールも AWS から公開されていますが、残念ながらこれも Google Apps Script 環境では実行できません。

AWS SDK が対応していない環境や言語の開発者向けに「Authenticating Requests (AWS Signature Version 4)」等いくつかのドキュメントが提供されています。

npm モジュールの利用

#

Google Apps Script では Node.js のような依存モジュールのコードを単一のコードにパッケージしなければなりません。この目的で、ここでは webpack を使用することにします。

環境構築

#

では、開発環境の構築から説明します。

Google Apps Script を GitHub でコード管理をするため Google から提供される cli ツール clasp をインストールします。

npm install -g @google/clasp

プロジェクトの初期化

#

作成するプロジェクトのディレクトリを作成し、そのディレクトリで次の初期化コマンドを実行します。

npm init

webpack 等のインストール

#

webpack、jest (テストツール) をインストールします。

npm i --save-dev webpack webpack-cli gas-webpack-plugin jest

package.json ファイルの scripts を次のように記述します。

  "scripts": {
    "test": "jest",
    "webpack": "webpack"
  }

webpack.config.js ファイルを次の内容で作成します。

const path = require('path');
const GasPlugin = require('gas-webpack-plugin');

module.exports = {
    mode: 'production',
    entry: {
        main: path.resolve('./src', 'Code.js')
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'Code.js',
        library: {
            name: 'Code',
            type: 'var'
        }
    },
    plugins: [new GasPlugin()]
}

Google Apps Script の初期化

#

dist ディレクトリを Google Apps Script のプロジェクトとして初期化します。

dist ディレクトリに移動します。

mkdir dist
cd dist
clasp login
clasp create --title 'gas-example'
cd ..

開発

#

検証方法の確立

#

まず、正しい署名とはどのような値で、それをどうやって検証するかから始めます。

これには、AWS から提供される署名モジュール @aws-sdk/signature-v4 を利用します。さらに暗号化に必要な @aws-crypto/sha256-js もインストールします。

npm i --save-dev @aws-sdk/signature-v4 @aws-crypto/sha256-js

src ディレクトリに put-object.test.js ファイルを次の内容で作成します。後ろのコメントアウト (/**/ で囲んだ) 部分の説明は後述します。

const { SignatureV4 } = require('@aws-sdk/signature-v4');
const { Sha256 } = require('@aws-crypto/sha256-js');

test ('sign', async () => {
    const signerInit = {
        service: 's3',
        region: 'ap-northeast-3',
        'sha256': Sha256,
        credentials: {
            accessKeyId: 'foo',
            secretAccessKey: 'bar'
        }
    };

    const signer = new SignatureV4(signerInit);
    const bucketName = 'mybucket';
    const contentType = 'application/json';

    const request = {
        'method': 'PUT',
        'protocol': 'https:',
        'path': '/my.json',
        'headers': {
            'host': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`,
            'Content-Type': contentType,
            'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD',
            'X-Amz-Security-Token': 'baz'
        },
        'hostname': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`
    };

    const signingDate = new Date('2000-01-01T00:00:00.000Z');
    const { headers } = await signer.sign(
        request,
        { 'signingDate': signingDate }
    );
    
    console.log(headers);

    /*
    const Signature = require('./signature-v4');
    const target = new Signature(signerInit.service
        ,signerInit.region
        ,signerInit.credentials.accessKeyId
        ,signerInit.credentials.secretAccessKey);

    const res = target.sign(signingDate, {
        method: 'PUT',
        protocol: 'https:',
        path: request.path,
        headers: {
            'host': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`,
            'Content-Type': contentType,
            'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD',
            'X-Amz-Security-Token': 'baz'
        },
        'hostname': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`
    });

    console.log(res.headers);

    expect(res.headers['Authorization']).toEqual(headers['authorization']);
    */
});

このコードをテスト実行すると、S3 にファイルをアップロード (PutObject) 時に使用する署名を含む追加のヘッダが表示されます。

これを足がかりに同じヘッダが生成されるようにコードを作成します。

暗号モジュールのインストール

#

Google Apps Script で動作する暗号モジュールを依存関係に追加します。

npm i --save crypto-js

src ディレクトリに signature-v4.js ファイルを次の内容で作成します。

const sha256 = require('crypto-js/sha256');
const hmac = require('crypto-js/hmac-sha256');
const hex = require('crypto-js/enc-hex');

class Signature {
    
    constructor(service, region, access_key_id, secret_access_key) {
        this.service = service;
        this.region = region;
        this.access_key_id = access_key_id;
        this.secret_access_key = secret_access_key;
    }
    
    addZero(s) {
        return (Number(s) < 10 ? '0' : '') + String(s);
    }

    dateStringFull(d) {
        return String(d.getUTCFullYear()) + this.addZero(d.getUTCMonth()+1) + this.addZero(d.getUTCDate()) + "T" + this.addZero(d.getUTCHours()) + this.addZero(d.getUTCMinutes()) + this.addZero(d.getUTCSeconds()) + 'Z';
    }
   
    dateStringShort(d) {
        return String(d.getUTCFullYear()) + this.addZero(d.getUTCMonth()+1) + this.addZero(d.getUTCDate());
    }
    
    getSignatureKey(key, dateStamp, regionName, serviceName) {
        var kDate = hmac(dateStamp, "AWS4" + key);
        var kRegion = hmac(regionName, kDate);
        var kService = hmac(serviceName, kRegion);
        var kSigning = hmac("aws4_request", kService);

        return kSigning;
    }
    
    fixedEncodeURIComponent(str) {
        return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
          return '%' + c.charCodeAt(0).toString(16).toUpperCase();
        });
    }

    headers(h) {
        return Object.keys(h).sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1).reduce((acc, k) => {
            acc += k.toLowerCase() + ':' + h[k] + '\n';
            return acc;
        }, '');
    }

    signedHeaders(h) {
        return Object.keys(h).sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1).reduce((acc, k) => {
            if (acc) {
                acc += ';' + k.toLowerCase();
            } else {
                acc = k.toLowerCase();
            }
            return acc;
        }, '');
    }

    query(q) {
        return Object.entries(q).sort((a, b) => a[0] < b[0] ? -1 : 1).reduce((acc, [key, value]) => {
            if (acc) {
                acc += '&' + key + '=' + this.fixedEncodeURIComponent(value);
            } else {
                acc = key + '=' + this.fixedEncodeURIComponent(value);
            }
            return acc;
        }, '');
    }

    sign(signingDate, request) {
        const dateStringFull = this.dateStringFull(signingDate);
        const dateStringShort = this.dateStringShort(signingDate);

        request['headers']['X-Amz-Date'] = this.dateStringFull(signingDate);
        
        const algorithm = 'AWS4-HMAC-SHA256';
        const scope = dateStringShort + '/' + this.region + '/' + this.service + '/aws4_request';

        const headers = this.headers(request.headers);
        const signedHeaders = this.signedHeaders(request.headers);
        
        const query = this.query(request.query ? request.query : {});

        const canonicalString = request.method + '\n'
            + request.path + '\n'
            + query + '\n'
            + headers + '\n'
            + signedHeaders + '\n'
            + request.headers['X-Amz-Content-Sha256'];
       
        const canonHash = hex.stringify(sha256(canonicalString));

        const stringToSign = algorithm + '\n'
            + dateStringFull + '\n'
            + scope + '\n'
            + canonHash;
       
        const key = this.getSignatureKey(this.secret_access_key, dateStringShort, this.region, this.service);
        const signature = hex.stringify(hmac(stringToSign, key));

        request.headers['Authorization'] = `${algorithm} Credential=${this.access_key_id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
        return request;
    }
}

module.exports = Signature;

テスト

#

前述のコードの検証をするため、put-object.test.js の後ろの方にある /**/ を削除してコメント解除します。

コードは次のようになります。

const { SignatureV4 } = require('@aws-sdk/signature-v4');
const { Sha256 } = require('@aws-crypto/sha256-js');

test ('sign', async () => {
    const signerInit = {
        service: 's3',
        region: 'ap-northeast-3',
        'sha256': Sha256,
        credentials: {
            accessKeyId: 'foo',
            secretAccessKey: 'bar'
        }
    };

    const signer = new SignatureV4(signerInit);
    const bucketName = 'mybucket';
    const contentType = 'application/json';

    const request = {
        'method': 'PUT',
        'protocol': 'https:',
        'path': '/my.json',
        'headers': {
            'host': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`,
            'Content-Type': contentType,
            'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD',
            'X-Amz-Security-Token': 'baz'
        },
        'hostname': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`
    };

    const signingDate = new Date('2000-01-01T00:00:00.000Z');
    const { headers } = await signer.sign(
        request,
        { 'signingDate': signingDate }
    );
    
    console.log(headers);

    const Signature = require('./signature-v4');
    const target = new Signature(signerInit.service
        ,signerInit.region
        ,signerInit.credentials.accessKeyId
        ,signerInit.credentials.secretAccessKey);

    const res = target.sign(signingDate, {
        method: 'PUT',
        protocol: 'https:',
        path: request.path,
        headers: {
            'host': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`,
            'Content-Type': contentType,
            'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD',
            'X-Amz-Security-Token': 'baz'
        },
        'hostname': `${bucketName}.${signerInit.service}.${signerInit.region}.amazonaws.com`
    });

    console.log(res.headers);

    expect(res.headers['Authorization']).toEqual(headers['authorization']);
});

このテストコードは、最終行の expect で AWS SDK モジュールを使って生成された authorization ヘッダと、前述のコードが生成した Authorization が同値か確認しています。

ライブラリとして使用できるようにする

#

Google Apps Script のライブラリに作成したコードを利用できるようにするため、Code.js ファイルを次の内容で作成します。

global.Signature = require('./signature-v4');

デプロイ

#

作成したコードを Google Drive にデプロイする手順は次のとおりです。

npm run webpack
cd dist
clasp push
cd ..

おわりに

#

この記事から少し拡張したコード全体が GitHub リポジトリにあります。

署名には認証情報 (ACCESS KEY ID と SECRET ACCESS KEY) が必要になります。

IAM ユーザに関連づけられた認証情報は、永続的な認証情報と呼ばれるものです。永続的な認証情報は公開すると短時間のうちに悪用されます。このようなインシデントも頻繁に発生しています。有効な認証情報をコードに絶対含めないでください。

IAM ロールを使用して一時的な認証情報を取得し、またロールにアタッチするポリシーでアクセス可能なリソースは最小限にしてください (最小権限の原則)。

参考

#

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。