KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

AWS KMS RSA 非対称鍵を利用したハイブリッド暗号化の方法 (OpenSSL/TypeScript)

はじめに

処方箋データ基盤チームでエンジニアをしている岩佐 (孝浩) です。
カケハシには「岩佐」さんが複数名在籍しており、社内では「わささん」と呼ばれています。

私が所属する処方箋データ基盤チームは、日本全国の薬局から送信される処方箋データを S3 に保存しています。
処方箋データは TLS 暗号で送信されますが、送信中のデータに対する盗聴リスクや改ざんリスクをさらに低減するために、AWS KMS RSA 非対称鍵を利用したハイブリッド暗号化を検証しました。

この投稿では、AWS KMS RSA 非対称鍵を利用したハイブリッド暗号方式での暗号化について、OpenSSL で暗号化して TypeScript で復号化する方法を紹介します。

ハイブリッド暗号化について

ハイブリッド暗号化は、対称鍵暗号非対称鍵暗号を組み合わせた暗号化方式です。
それぞれの暗号方式の強みを活かして、大量データを効率的に暗号化できます。

ハイブリッド暗号化のフロー

  1. データを暗号化するための対称鍵 (Data Encryption Key: DEK) を生成
  2. データを DEK で暗号化
  3. DEK を非対称鍵 (Key Encryption Key: KEK) で暗号化
  4. 暗号化されたデータと鍵をセットで保管
  5. 暗号化された鍵を秘密鍵で復号化し DEK を抽出
  6. 暗号化されたデータを DEK で復号化

AWS KMS を利用した場合のイメージ:

Hybrid Encryption using AWS KMS

AWS KMS を利用することで、秘密鍵の管理を AWS に任せることができるので、鍵の漏洩リスクを低減できます。
また、アクセス制御を IAM ポリシーで厳密に設定することで、安全に鍵を管理できます。

RSA 非対称鍵で暗号化できるデータサイズ

RSA 非対称鍵で暗号化できるデータサイズは、下記のとおり非常に小さいサイズになります。公開鍵暗号の仕組み上、暗号化処理が複雑で計算コストが高いためです。

鍵長 (bit) パディング パディングサイズ (byte) データサイズ (byte)
2048 PKCS#1 v1.5 11 2048 / 8 - 11 = 245
2048 OAEP SHA-256 2 * 32 + 2 = 66 2048 / 8 -66 = 190
4096 PKCS#1 v1.5 11 4096 / 8 - 11 = 501
4096 OAEP SHA-256 2 * 32 + 2 = 66 4096 / 8 -66 = 446

ほとんどのユースケースでは上記のデータサイズを超過するため、対称鍵暗号方式かハイブリッド暗号方式のいずれかを採用することになります。

KMS RSA 非対称鍵を利用したハイブリッド暗号化

1. データを暗号化するための対称鍵 (Data Encryption Key: DEK) を生成

以下のコマンドを実行して、データを暗号化するための Data Encryption Key (DEK) を生成します。

openssl rand -base64 32 > data-encryption.key

2. データを DEK で暗号化

以下のコマンドを実行して、カケハシのテックブログのトップページをダウンロードします。
これを暗号化のサンプルデータとして利用します。

curl https://kakehashi-dev.hatenablog.com/ > data.txt

DEK を利用して、サンプルデータを暗号化します。

openssl enc \
  -aes-256-cbc \
  -salt \
  -pbkdf2 \
  -in data.txt \
  -out data.txt.enc \
  -pass file:./data-encryption.key

コマンドのオプション:

オプション 説明
-aes-256-cbc 暗号アルゴリズム (AES 256 CBC)
-salt 暗号化時にランダムなソルトを生成
-pbkdf2 パスワードから暗号化キーを導出する際に使用される鍵導出関数
-in 暗号化したいファイル
-out 暗号化されたファイル
-pass 暗号化に利用するパスワード

暗号化が完了したら、元のファイルを削除します。

rm data.txt

3. DEK を非対称鍵 (Key Encryption Key: KEK) で暗号化

KMS RSA 非対称鍵の作成

以下のコマンドを実行して、KMS に RSA 非対称鍵を作成します。
--key-spec は、要件に応じて適切な鍵の長さを選択してください。
この投稿では 4096 ビットを選択しています。

aws kms create-key \
  --key-spec RSA_4096 \
  --key-usage ENCRYPT_DECRYPT

公開鍵のダウンロード

以下のコマンドを実行して、公開鍵をダウンロードします。
この公開鍵を Key Encryption Key (KEK) として利用します。

aws kms get-public-key \
  --key-id <KMS_KEY_ID> \
| jq -r .PublicKey | base64 -d > kms-public-key-encryption.key

補足:

KMS に保管されている秘密鍵を取得することはできません。
このおかげでセキュアな運用が可能になりますが、セルフマネージドな秘密鍵を利用したい場合、CloudHSM 等のサービスをご検討ください。

KEK (公開鍵) で DEK を暗号化

以下のコマンドを実行して、KEK (公開鍵) で DEK を暗号化します。
パディングには OAEP SHA-256 を指定しています。

openssl pkeyutl \
  -in data-encryption.key \
  -out encrypted.key \
  -inkey kms-public-key-encryption.key \
  -pubin \
  -encrypt \
  -pkeyopt rsa_padding_mode:oaep \
  -pkeyopt rsa_oaep_md:sha256

DEK はパスワードに相当するので、暗号化したらすぐに削除します。

rm data-encryption.key

KEK は公開鍵のため削除不要ですが、この投稿では利用することが無いため削除しておきます。

rm kms-public-key-encryption.key

4. 暗号化されたデータと鍵をセットで保管

この時点で以下のファイルが存在しているはずです。

.
├── data.txt.enc
└── encrypted.key

復号化 TypeScript

要件

以下のコマンドを実行して、必要なライブラリをインストールしてください。

npm i @aws-sdk/client-kms
npm i -D @types/node tsx typescript
ライブラリ 用途
@aws-sdk/client-kms AWS SDK for JavaScript KMS Client
@types/node Node.js 型定義
tsx TypeScript をトランスパイルせずに直接実行するツール
typescript プログラミング言語

サンプルコード

先にサンプルコードの全体を掲載しておきます。
main.ts というファイル名で保存してご利用ください。

import { Buffer } from 'node:buffer';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { DecryptCommand, KMSClient } from '@aws-sdk/client-kms';

const deriveDataEncryptionKey = async (
  encryptedKey: Buffer<ArrayBufferLike>,
  kmsKeyId: string,
): Promise<string> => {
  const command = new DecryptCommand({
    KeyId: kmsKeyId,
    CiphertextBlob: encryptedKey,
    EncryptionAlgorithm: 'RSAES_OAEP_SHA_256',
  });
  const text = (await new KMSClient().send(command)).Plaintext;
  if (!text) {
    return '';
  }
  return new TextDecoder('utf-8').decode(text).replace('\n', '').trim();
};

const decryptFile = (filePath: string, password: string): Buffer => {
  const encryptedData = fs.readFileSync(filePath);
  const salt = encryptedData.subarray(8, 16);
  const derivedKey = crypto.pbkdf2Sync(
    password,
    salt,
    10000,
    32 + 16, // 32 bytes key + 16 bytes IV
    'sha256',
  );
  const aesKey = derivedKey.subarray(0, 32);
  const iv = derivedKey.subarray(32);

  const decipher = crypto.createDecipheriv('aes-256-cbc', aesKey, iv);
  return Buffer.concat([
    decipher.update(encryptedData.subarray(16)), // ソルト以降のデータを復号
    decipher.final(), // 最後の復号ブロック
  ]);
};

const main = async (
  keyEncryptionKeyPath: string,
  encryptedFilePath: string,
  kmsKeyId: string,
): Promise<void> => {
  // Read encrypted key
  const encryptedKey = fs.readFileSync(keyEncryptionKeyPath);
  
  // Derive data encryption key
  const dataEncryptionKey = await deriveDataEncryptionKey(
    encryptedKey,
    kmsKeyId,
  );
  
  // Decrypt
  const decryptedData = decryptFile(encryptedFilePath, dataEncryptionKey);
  
  // Write decrypted data
  const filename = `decrypted-${path.basename(encryptedFilePath).replace(/\.enc$/, '')}`;
  fs.writeFileSync(
    path.join(path.dirname(encryptedFilePath), filename),
    decryptedData,
  );
};

main(
  process.argv[2], // encrypted.key
  process.argv[3], // data.txt.enc
  process.argv[4], // AWS KMS key ID
);

5. 暗号化された鍵を秘密鍵で復号化し DEK を抽出

下記のコードは、暗号化された鍵を KMS 秘密鍵で復号化し DEK を抽出するための関数です。

const deriveDataEncryptionKey = async (
  encryptedKey: Buffer<ArrayBufferLike>,
  kmsKeyId: string,
): Promise<string> => {
  const command = new DecryptCommand({
    KeyId: kmsKeyId,
    CiphertextBlob: encryptedKey,
    EncryptionAlgorithm: 'RSAES_OAEP_SHA_256',
  });
  const text = (await new KMSClient().send(command)).Plaintext;
  if (!text) {
    return '';
  }
  return new TextDecoder('utf-8').decode(text).replace('\n', '').trim();
};

ポイント:

  • AWS KMS の DecryptCommand API を使用
  • 復号化された DEK を文字列として返却

6. 暗号化されたデータを DEK で復号化

下記のコードは、暗号化されたデータを DEK で復号化するための関数です。

const decryptFile = (filePath: string, password: string): Buffer => {
  const encryptedData = fs.readFileSync(filePath);
  const salt = encryptedData.subarray(8, 16);
  const derivedKey = crypto.pbkdf2Sync(
    password,
    salt,
    10000,
    32 + 16, // 32 bytes key + 16 bytes IV
    'sha256',
  );
  const aesKey = derivedKey.subarray(0, 32);
  const iv = derivedKey.subarray(32);

  const decipher = crypto.createDecipheriv('aes-256-cbc', aesKey, iv);
  return Buffer.concat([
    decipher.update(encryptedData.subarray(16)), // ソルト以降のデータを復号
    decipher.final(), // 最後の復号ブロック
  ]);
};

ポイント:

  • ソルトは先頭8バイト (Salted__) の次の8バイトに格納
  • ストレッチングは 10,000 回 (OpenSSL デフォルト値)
  • 導出する鍵は 32バイト (AES 256 bit) + 16バイト (初期化ベクトル)
  • 復号化されたデータをバイナリ (Buffer) として返却

以下のコマンドを実行して、TypeScript を実行します。

npx tsx main.ts \
  encrypted.key \
  data.txt.enc \
  <KMS_KEY_ID>

スクリプトを実行すると、復号化されたデータが decrypted-data.txt に出力されます。

head -10 decrypted-data.txt
<!DOCTYPE html>
<html
  lang="ja"

data-admin-domain="//blog.hatena.ne.jp"
data-admin-origin="https://blog.hatena.ne.jp"
data-author="kakehashi_dev"
data-avail-langs="ja en"
data-blog="kakehashi-dev.hatenablog.com"
data-blog-host="kakehashi-dev.hatenablog.com"

まとめ

AWS KMS RSA 非対称鍵を利用したハイブリッド暗号方式での暗号化について、OpenSSL で暗号化して TypeScript で復号化する方法を具体的な手順を通じて紹介しました。

KMS を利用すると、非対称鍵の管理と運用が大幅に簡素化されます。
主なポイントは以下のとおりです。

  • 秘密鍵が KMS 内で安全に管理され、漏洩リスクが低減
  • IAM ポリシーを利用した厳密な権限設定
  • 高い可用性

紹介した TypeScript のサンプルコードは、AWS Lambda で実行することも可能です。
信頼性の低いネットワークを通じてデータを連携する際に、セキュリティを高めることができます。

以上、お役に立てれば幸いです。

補足:非対称鍵を利用したハイブリッド暗号化の検証理由

今回の検証と同様の暗号化方法として、AWS KMS ではエンベロープ暗号化の利用が一般的です。

今回の検証では以下が要件になっており、非対称鍵を利用したハイブリッド暗号化を試しました。

  • 複数のクライアントに公開鍵を事前に共有
  • クライアントに AWS CLI や AWS SDK のインストール不可
  • クライアントとサーバー間の通信経路で Data Encryption Key が露出しないこと

Asymmetric keys in AWS KMS

If your use case requires encryption outside of AWS by users who cannot call AWS KMS, asymmetric KMS keys are a good choice.

参考リソース