実践 AWS Key Management Service

WebPayはサーバインフラの大部分でAWSを利用しており、クラウドサービスもAWSのものを好んで利用しています。 今回、新たにカード会員データを取り扱う部分を再構築するにあたり、AWS Key Management Serviceについてサーベイしました。

この記事ではその過程で得られた知見を、PCI DSSとの関係や実際のコードに触れながら紹介します。 カード決済に限らず、セキュリティが重要なシステムを構築しているエンジニアに是非読んで欲しい内容になっています。

AWS Key Management Serviceとカード会員データ

AWS Key Management Service(以後KMS)は、端的にはデータの暗号化、復号化を実施するサービスです。 具体的な利用法や特徴については公式ドキュメント What is the AWS Key Management Service?に譲ることにして、ここでは今回の利用目的である、カード会員データの保護とどう関係しているのか説明します。

WebPayはPCI DSSに準拠しているため、カード会員データ(クレジットカード情報)の保存においてもPCI DSSの要件を満たす必要があります。 その上でさらにセキュリティ上の問題点、脆弱性がないかを確認し、安全なサービスの提供に努めています。

PCI DSS v3.0の要件3.「保存されるカード会員データを保護する」では3.4でPAN(カード番号)を暗号化して保存することを定めており、さらに、カード会員データの暗号化に利用する暗号化キーを次の1つ以上の条件下で保存する必要があるとしています(要件3.5.2)。

  1. 少なくともデータ暗号化キーと同じ強度のキー暗号化キーで暗号化されており、データ暗号化キーとは別の場所に保存されている
  2. 安全な暗号化デバイス(ホストセキュリティモジュール(HSM)、またはPTS承認の加盟店端末装置など)内
  3. 業界承認の方式に従う、少なくとも2つの全長キーコンポーネントまたはキー共有として

KMSは内部でHSM(Hardware Security Module)を利用しているため(AWS Key Management Service解説より)、2の要件を満たすものとして利用することもできます。 KMSが提供するのはAES-256-GCMであり(KMS Cryptographic Details whitepaper(PDF)のp.5 Backgroundより)、言うまでもなく十分にセキュアな暗号形式です。

当初IV(初期化ベクトル)が指定できないことを気にかけていましたが、GCMのアルゴリズムにはIVを指定することができ、同一のクエリに対しても異なる結果が得られることから、おそらく内部でIVを自動的に作成してCiphertextBlobのメタデータの中に含めていると予想されます。 暗号文を大量に作成することで秘密を推測するような攻撃も難しいことが分かりました。

しかし、KMSはHSMの仕様上、鍵を取り出すような操作は行えず、暗号化、復号化には都度HTTPリクエストを実施する必要があります。 aws-cliからの実行結果ですが、暗号化操作に平均0.5秒程要するため、カード情報を登録、利用するたびにKMSにリクエストを行うのは非現実的です。

KMSもそのような用途は想定しておらず、KMSに置いたマスターキーから、それで暗号化されたデータ暗号化キーを作成して利用するAPIを用意しています。 しかし、すべての暗号化が必要なデータについて異なるデータ暗号化キーを作成すると、やはり登録、利用のたびにデータ暗号化キーの作成、復号化が必要となります。

そこで今回は単一のデータ暗号化キーで上述のPCI DSSの要件3.5.2の手段1を満たすためにKMSを利用することにしました。 データ暗号化キーとして、AES-256用のものをKMSのデータキー生成APIで作成しました。 この暗号化されたキーはS3に保存しておきます。 攻撃者はデータベース、S3、KMSのすべてへのアクセス権限を奪取しないとデータベース内に保存されたデータを利用できないので、十分にセキュアなシステムになるといえます。

KMSとS3を利用したシステムの構築

上述のように、データ暗号化キーと、KMSで管理するキー暗号化キーの二重構成にすることになったので、利用フローは次のようになります。 運用開始時に管理者がデータ暗号化キー作成API(kms generate-data-key)を利用して暗号化されたデータ暗号化キーを取得し、これをAWS S3に保存します。 アプリケーションはデータ暗号化キーが最初に必要になった時にS3から暗号化されたキーを取得し、KMSで復号化します(decrypt)。 以降はこれをキャッシュして利用します。

このためのアプリケーション用のユーザへのアクセス権の管理は、次のように実施します。

  • S3の暗号化されたデータ暗号化キーファイルの読み取り権限をIAMで付与する
  • KMSのcreate-grantを使って該当の鍵のDecrypt利用権限を付与する

create-grantで利用権限を付与しているのは、IAMではarnを設定できず、粒度の細かい方法が用意されていないためです。

今回のアプリケーションコードはJavaとC++で作成していました。 事情により、データの作成側と利用側で言語が異なっています。

Javaでの利用法はドキュメントにもある通りですが、全体を通して実装すると次のようになります(AWS SDKのほかにGuavaを利用しています)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
AmazonS3Client s3 = new AmazonS3Client(credentials);
S3Object dataKeyObject = s3.getObject(encryptedDataKeyBucket, encryptedDataKeyLocation);

byte[] rawEncrypted;
try (S3ObjectInputStream is = dataKeyObject.getObjectContent()) {
    byte[] base64Encrypted = new byte[(int) dataKeyObject.getObjectMetadata().getContentLength()];
    ByteStreams.readFully(is, base64Encrypted);
    rawEncrypted = Base64.getDecoder().decode(base64Encrypted);
} catch (IOException e) {
    throw new RuntimeException("Could not read encrypted data key in S3");
}

AWSKMSClient kms = new AWSKMSClient(credentials);

kms.setEndpoint("https://kms.ap-northeast-1.amazonaws.com");

// EncryptionContext is required to avoid Java SDK bug
// https://github.com/aws/aws-sdk-java/issues/309
DecryptRequest request = new DecryptRequest()
        .withCiphertextBlob(ByteBuffer.wrap(rawEncrypted))
        .addEncryptionContextEntry("avoid", "bug");
DecryptResult result = kms.decrypt(request);
cachedKey = new SecretKeySpec(result.getPlaintext().array(), "AES");

注意として、現在のJava SDKの実装にはバグがあり、EncryptionContextなしで生成された鍵を復号化することができません。 GitHubの以下のissueにて触れられています。

これを回避するためにこのコードではavoid:bugというcontextを指定しています。

次にC++ですが、こちらは標準提供のSDKがないため、少し手間がかかります。 特にKMSは新しいサービスのためか、調べた範囲のサードパーティ製ライブラリではサポートしていませんでした。 そこでS3のファイル取得とKMSのDecryptだけを行うための簡単なAPIクライアントを実装しました。

S3のファイル取得はhttps://[bucket].s3.amazonaws.com/[location]へのGETリクエストになります。 Signature Version 4でリクエストに署名する必要がありますが、署名対象のヘッダとしてX-Amz-Content-Sha256が必須であることに注意してください。 このことはドキュメントでは十分に触れられていませんが、これが無いとエラーになります。 GETリクエストではbodyは空文字列なので、値には空文字列のSHA-256ハッシュ値の小文字16進数表記を指定します。

KMSでは次のようなリクエストを送信します。

1
2
3
4
5
6
7
8
9
POST / HTTP/1.1
Host: kms.ap-northeast-1.amazonaws.com
Authorization: AWS4-HMAC-SHA256 [signature]
X-Amz-Date: 20141129T000000Z
X-Amz-Target: TrentService.Decrypt
Content-Type: application/x-amz-json-1.1
Content-Length: 329

{"CiphertextBlob":"Base64エンコードされた暗号文","EncryptionContext":{"avoid":"bug"}}

これをSignature Version 4の仕様にそってCanonical Requestに変更し、POSTリクエストを行うとJSON形式のレスポンスが得られます。 {"avoid":"bug"}は上述のJavaのバグを回避するためなので、C++やJava以外のSDKでしか利用しない場合は不要です。 あるいはサービスで利用したいAADを指定してください。

まとめ

AWSはHSMのソリューション、AWS CloudHSMも提供していますが、Upfrontの価格もランニングコストも非常に高価な製品です。 KMSはHSMのセキュリティを月1ドルの低価格で利用できる点で非常に魅力的な製品でした。 その分、速度面や暗号化の自由度などいくつかの制約がかかりますが、今回のように用途に合わせて全体のフローを設計することで、HSMの高いセキュリティ性能を効率よく利用できます。

AWSのAPIクライアントを作成する過程では、AWSの署名メカニズムの面白さがわかりました。 署名のメカニズム自体も学ぶところが多いものですが、間違った署名を送信した時には他のヘッダなどのリクエストパラメータからAWS側が計算した署名対象の文をレスポンスしてくれます。 手元で署名している文とどこが違っているかを探していくことで、どのステップで間違ったかがすぐに分かるのでとてもデバッグしやすかったです。 WebPayにとっても、エラーメッセージの改善において学ぶべきところがあると実感しました。

本記事中のソースコードはWebPayが著作権を保持しますが、Public domainで利用いただけます。 ぜひKMSを利用してさらにセキュアなシステムを効率よく構築してみてください。