GCP Cloud KMSで復号がうまくできなかった話

# この記事について
GCPを利用して、下記のようなアプリケーションの開発を目指しています。
- 機密情報を暗号化する
- 暗号化した機密情報をデータストアに保存する
- 保存された機密情報を復号し、処理に利用する

そこで、簡単な処理を実装し、暗号化・復号に用いるCloud KMSや、
データの保存に用いるFirestoreのキャッチアップを行っていました。

しかし、Cloud KMSを用いた復号でつまずいたので、その内容と解決策についての記事です。

# どこでつまずいたか
Cloud KMSを用いたデータの暗号化と復号の方法に関しては、[公式のドキュメント](https://cloud.google.com/kms/docs/encrypt-decrypt#encrypt)に記載されています。

このドキュメントでは、ファイルを経由したデータの暗号化・復号の方法が解説されています。
具体的には、
1. ファイルに平文を記述
2. ファイルに記述された平文を読み込み、暗号化
3. 暗号化されたテキストをファイルに記述
4. ファイルに記述された暗号化テキストを読み込み、復号
5. 復号して取得した平文をファイルに記述
という方法です。

自分の場合は、Cloud KMSが正しく動作することを手っ取り早く確認したかったことと、
実際の利用時には暗号化されたテキストを、ファイルではなくデータベースに保存しようと考えていたため、
ファイルを利用せずに暗号化・復号のロジックを実装しました。

しかし、暗号化されたテキストを復号しようとしても、うまく復号ができず、
空の実行結果が返されてしまいました。

# まずは解決策
とにかく早く解決策を知りたい方のために、まず解決策を書きます。
GCPのCloud KMSでは、
- **暗号化の際は、平文をエンコードしたバイナリデータを渡す**
- **復号されたデータは、デコードする**
ということが必要です。

# もっと詳しく
今後のサービス開発で利用する予定であるCloud KMSとCloud Firestoreのキャッチアップのため、
以下のような構成で処理を実装しました。

## 環境構成と処理の流れ
### 暗号化の処理の流れ
![encryption.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614712/a36d7239-52c3-017f-7404-2fa1eb9ae841.jpeg)

実際の暗号化やデータベースへの保存の処理は、CloudFunctions上で行っています。
① CloudFunctionsはAPIリクエストで起動し、その際にデータベース保存用のIDと暗号化したいテキストをクエリとして渡します
② 渡されたテキストをCloud KMSを利用して暗号化します
③ 暗号化されたテキストをFirestoreに保存します

### 復号の処理の流れ
![decryption.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614712/f60a1e4c-c158-3e6a-c0a3-988c6463ba26.jpeg)

① CloudFunctionsをAPIリクエストで起動、その際に取得したいデータのIDをクエリとして渡します
② 渡されたIDをもとに、Firestoreからデータを取得します
③ 取得した暗号化されたテキストを復号します

## 環境の準備と処理の実行まで
### Cloud Key Management Serviceで暗号鍵の作成
Cloud KMSには、暗号鍵（キー）と、暗号鍵を束ねるキーリングというものがあります。
暗号鍵作成するためには、まずキーリングを作成し、そのあとで暗号鍵を作成します。

1. GCPコンソール上部の検索ボックスに、「暗号鍵」と入力し、KMSのページへ行く

2. 画面上部の「キーリングを作成」ボタンをクリックする

3. 下記の情報を入力し、キーリングを作成する
    - キーリングの名前: 任意の名前を設定
    - キーリングのロケーション: とりあえず「global」を選択

4. 続けて、暗号鍵を作成する
    - 生成する鍵の種類: 「生成した鍵」を選択
    - 鍵名: 任意の名前を設定
    - 保護レベル: とりあえず「ソフトウェア」を選択
    - 目的: 「対称暗号化 / 復号化」を選択
    - ローテーション期間: キャッチアップのための利用なので、「実行しない」を選択

### Cloud Firestoreでコレクションを作成
Firestoreのコレクションとは、ざっくりいうとRDBでいうところのテーブルのようなものです。
正確な理解をしたい方は、[ドキュメント](https://firebase.google.com/docs/firestore/data-model?hl=ja)を参照ください。

1. GCPコンソール上部の検索ボックスに、「Firestore」と入力し、Firestoreのページへ行く

2. 画面左側の「コレクションを開始」ボタンをクリックする

3. 下記の情報を入力し、保存ボタンをクリックし、コレクションを作成する
    - コレクションID: 任意の名前を設定

### 処理ロジックの実装
今回は、Node.jsで実装しています。
大事なポイントには、コメントをしています。


```js:kms-test.js
const kms = require('@google-cloud/kms');
const admin = require('firebase-admin');
const functions = require('firebase-functions');

const client = new kms.KeyManagementServiceClient();
// projectName, keyringName, keyNameには、実際のプロジェクト名, キーリング名, キー名を設定する
const name = client.cryptoKeyPath('projectName', 'global', 'keyringName', 'keyName');

admin.initializeApp(functions.config().firebase);
const db = admin.firestore();
// collectionNameには、Firestoreのコレクション名を設定する
const COLLECTION_NAME = 'collectionName';

exports.handler = async (req, res) => {
    // APIリクエストのクエリ isEncryption によって、暗号化を行うか復号を行うか決定する
    const isEncryption = req.query.isEncryption === '1' ? true : false;

    if(isEncryption) {
        // 暗号化の場合の処理
        console.log('encryption mode');

        // APIリクエストのクエリから、idとmessageを取得する
        // - id: Firestoreドキュメントのキーとして利用
        // - message: 暗号化したい文字列
        const { id, message } = req.query;
        console.log(`id: ${id}`);
        console.log(`message: ${message}`);

        const encryptionResult = await encrypt(message);

        storeCiphertext(id, encryptionResult.ciphertext);

        res.status(200).send('storing encrypted message succeeded.');
        return;

    } else {
        // 復号する場合の処理
        console.log('decryption mode');

        // APIリクエストのクエリから、復号したいデータのキーを取得
        const { id } = req.query;
        console.log(`id: ${id}`);

        const ciphertext = await getCiphertext(id);
        const message = await decrypt(ciphertext);

        // レスポンスとして、復号した文字列を返還する
        res.status(200).send(`decryption succeeded. (message: ${message})`);
        return;
    }
}

function storeCiphertext(id, ciphertext) {
    // Firestoreのコレクションに、idをキー、暗号化された文字列を値として格納する
    db.collection(COLLECTION_NAME).doc(id).set({ ciphertext });
}

function getCiphertext(id) {
    // Firestoreのコレクションを、idで検索し、データを取得する
    return new Promise((resolve, reject) => {
        db.collection(COLLECTION_NAME).doc(id).get()
        .then( doc => {
            if (!doc.exists) {
                console.log('no such document.');
                resolve();
            } else {
                console.log(`document data: ${JSON.stringify(doc.data())}`);
                resolve(doc.data()['ciphertext']);
            }
        }) .catch ( err => {
            console.log('error', err);
            reject(0);
        });

    })
}

async function encrypt(message) {
    // 暗号化したい文字列をバイナリデータにエンコードする
    const plaintext = Buffer.from(message, 'utf-8').toString('base64');
    const [result] = await client.encrypt({ name, plaintext });

    console.log(`encrypt result: ${JSON.stringify(result)}`);

    return result;
}

async function decrypt(ciphertext){
    const [result] = await client.decrypt({ name, ciphertext });
    console.log(`decrypt result: ${JSON.stringify(result)}`);

    // 復号した文字列をデコードする
    return Buffer.from(result.plaintext, 'base64').toString('utf-8');
}

```

### Cloud Functionsへのデプロイ
Node.jsで記述されたソースをCloud Functiosへデプロイするためには、package.jsonが必要です。

```json:package.json
{
  "name": "kms-test",
  "version": "1.0.0",
  "description": "",
  "main": "kms-test.js",
  "scripts": {},
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/kms": "^1.6.3",
    "firebase-admin": "^8.10.0",
    "firebase-functions": "^3.5.0",
  }
}
```

続いて、下記コマンドを実行し、Cloud Functionsへのデプロイを行います。
`gcloud functions deploy kms-test --region=asia-northeast1 --runtime=nodejs10 --trigger-http --entry-point handler`

ここでは、kms-testという関数を、asia-northeast1というリージョンに作成し、そこにソースコードをデプロイしています。

`--entry-point handler` では、kms-test.js内のhandlerというfunctionがエントリーポイントとして実行されるように指定をしています。
このオプションを指定しないと、Cloud Functionsでは、関数名と同名のfunctionをエントリーポイントとして設定しようとしますが、それが無いので、デプロイ時にエラーとなってしまいます。

デプロイに成功すると、下記のようなURLが発行されます。
`https://asia-northeast1-project-name.cloudfunctions.net/kms-test`
実際に処理を行う際に必要となるので、控えておいてください。

### 暗号化の実行
ここでは、curlコマンドを利用して、暗号化を行います。
`curl https://asia-northeast1-project-name.cloudfunctions.net/kms-test?isEncryption=1\&id=0001\&message=HelloWorld`
暗号化を行いたいので、isEncryption=1として、任意のidとmessageを渡します。

ちなみに、Macの標準シェルがzshになったことで、curlコマンドを実行すると
`zsh: no matches found: https://~~~`
というエラーが起こる場合があります。
事前に、 `setopt nonomatch` を実行してあげると、無事curlコマンドが実行できるようになります。

curlコマンドの実行結果として、下記が表示されれば、実行は成功です。
`storing encrypted message succeeded.`

Firestoreも確認してみます。
![firestore.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614712/038dd842-fc72-c98f-5a14-530be0b92108.png)

kms-testというコレクションのなかに、idで渡した0001をキーとするドキュメントが作成されています。
また、ドキュメントの値には、なにやら暗号化されたっぽい文字列が格納されています。

ひとまず、暗号化成功です。

### 復号の実行
続いて、先ほど暗号化した文字列を復号してみます。
以下のcurlコマンドを実行します。
`curl https://asia-northeast1-project-name.cloudfunctions.net/kms-test?isEncryption=0\&id=0001`
復号を行いたいので、isEncryption=0として、idには復号したいデータのidを指定します。

すると、下記結果が得られます。
`decryption succeeded. (message: HelloWorld)`

暗号化の際に与えた HelloWorld という文字列を取得できていることが確認できました。

復号も成功です。

## まとめ
Cloud KMSを利用してデータの暗号化・復号をする場合は、
- **暗号化の際は、平文をエンコードしたバイナリデータを渡す**
- **復号されたデータは、デコードする**
ということが必要です。

長くなってしまいましたが、同じところでつまずいてる人の役に立てると嬉しいです。