Play Framework × AWS KMS × Encryption SDKで実装するデータ暗号化
はじめに
個人情報や診断結果などの機微情報を扱う際は、データの暗号化が不可欠です。
AWSでは暗号鍵の生成・管理・ローテーションを担うサービス、AWS Key Management Service(以下、KMS)が提供されています。
今回は、KMSとAWS Encryption SDKとPlay Frameworkを組み合わせて、Todoデータを暗号化してデータベースに保存するアプリを実装してみたいと思います。
KMS
KMSは、暗号鍵のライフサイクル全体を安全に管理できるマネージドサービスです。
KMSを利用することで、以下のような操作をAWS上で一元的に管理できます。
- キーの作成・保管
暗号鍵をAWS内で安全に生成・保存できます。 - アクセス制御
IAMポリシーやKMSのキーポリシーを用いて、どのユーザーやサービスが鍵を利用できるかを制御します。 - キーのローテーション
KMSの自動ローテーションを有効化することで、1年ごとに新しいキーを自動生成します。
エンベロープ暗号化
KMSでデータを暗号化する際に推奨される構成が「エンベロープ暗号化(Envelope Encryption)」です。
これは、KMSキー(Key Encryption Key、以下KEK)で直接データを暗号化するのではなく、データキー(Data Encryption Key、以下DEK)を使って暗号化し、そのDEKをKEKで保護するという二段階構造の仕組みです。
暗号化の流れ
- KMSからデータ暗号化用の鍵(DEK)を生成する
- KMSは平文のDEKと、KEKで暗号化したDEKの両方を返す
- クライアントは平文のDEKを使って対象データを暗号化する
- KMSから返された暗号化済みDEKと暗号化データをセットで保存する
復号の流れ
- 暗号化データと一緒に保存していた暗号化済みDEKをKMSに渡し、KMSキー(KEK)で復号する
- KMSから返された平文のDEKを使って、クライアント側で暗号化済みデータを復号する

なぜエンベロープ暗号を使うのか
KEKで直接暗号化できるデータは最大4KBまでという制限があります。
そのため、アプリケーション側で大容量データをDEKで暗号化し、そのDEKをKMSで保護する構成を取ることで、大容量のデータを暗号化できます。
Encrypts plaintext of up to 4,096 bytes using a KMS key
AWS Encryption SDK
KMSを利用した暗号化処理を、開発者が意識せずに実装できるよう抽象化したライブラリです。
KMSを直接呼び出して行うような複雑な処理を、SDKが内部で安全にまとめて実行します。
通常であれば、GenerateDataKeyなどのKMSAPIを呼び出してDEKを生成、平文データをDEKで暗号化し、そのDEKをKEKで暗号化したりといった一連の処理をアプリケーション側で実装する必要があります。
AWS Encryption SDKを利用すると、これらの手順を自前で実装することなく、「暗号化したい」「復号したい」というシンプルな呼び出しのみで、内部的に必要なKMS連携と鍵管理を安全に実行できます。
実装
本章では、AWS Encryption SDKを用いてTodoデータを暗号化・復号する主要なコードを紹介します。
データベースアクセスやリクエストのJSONパース部分は本筋から外れるため省略します。
詳細な設定や全体コードは、GitHubリポジトリをご参照ください。
Play Framework プロジェクト作成
まず、Play FrameworkのScalaテンプレートから新規プロジェクトを作成します。
sbt new playframework/play-scala-seed.g8
build.sbt設定
AWS Encryption SDK for Javaを利用するためには、以下の依存関係が必要です。
- AWS SDK for Java (KMS)
- Bouncy Castle (bcprov-ext-jdk15on)
- AWS Encryption SDK for Java
また、今回は簡易的な検証のためH2 Databaseを使用します。
libraryDependencies ++= Seq(
"software.amazon.awssdk" % "kms" % "2.37.2",
"org.bouncycastle" % "bcprov-ext-jdk15on" % "1.70",
"com.amazonaws" % "aws-encryption-sdk-java" % "3.0.0",
)
libraryDependencies ++= Seq(
"com.h2database" % "h2" % "1.4.192",
"org.playframework" %% "play-slick" % "6.2.0",
"org.playframework" %% "play-slick-evolutions" % "6.2.0",
evolutions
)
libraryDependencies += "org.typelevel" %% "cats-core" % "2.13.0"
application.conf
H2 Consoleでデータベースの内容をブラウザから確認できるように、Server Modeを有効化します。
AUTO_SERVER=TRUEを設定することで、複数プロセスから同じDBに接続できます。
Many applications can connect to the same database at the same time, by connecting to this server.
aws.region = ${?AWS_REGION}
aws.keyArn = ${?AWS_KEY_ARN}
slick.dbs.default.profile="slick.jdbc.H2Profile$"
slick.dbs.default.db.driver="org.h2.Driver"
slick.dbs.default.db.url="jdbc:h2:file:./data/playdb;MODE=MYSQL;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=TRUE"
play.evolutions.db.default.autoApply = true
slick.dbs.default.db.user="sa"
slick.dbs.default.db.password=""
DBテーブル定義
Play FrameworkのEvolutions機能を利用して、アプリケーション起動時にテーブルを自動作成します。
conf/evolutions/default/1.sqlに以下のSQLを追加します。
-- Users schema
-- !Ups
CREATE TABLE TODO (
id bigint(20) NOT NULL AUTO_INCREMENT,
cipher_bytes BLOB NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
);
-- !Downs
DROP TABLE TODO;
サービス
サービス概要
このサービスはKMSを利用し、文字列データの暗号化および復号を行う共通処理を提供します。
@Singleton
case class EncryptionService @Inject() (configuration: Configuration) (implicit executionContext: ExecutionContext) {
...
}
Keyring初期化
AWS Encryption SDKでは、暗号化・復号時にKeyringを利用します。
Keyringは、どのKEKを用いてDEKを保護するかを定義する概念です。
今回はKMSを使用するため、KMS Keyringを作成します。
KMS Keyringは、KEKを利用してDEKを生成・暗号化・復号する仕組みを提供します。
AWS KMS キーリングは AWS KMS keysを使用してDEKを生成、暗号化、復号します。
val keyArn = configuration.get[String]("aws.keyArn")
val crypto = AwsCrypto.builder.withCommitmentPolicy(CommitmentPolicy.RequireEncryptRequireDecrypt).build
val materialProviders = MaterialProviders.builder.MaterialProvidersConfig(MaterialProvidersConfig.builder.build).build
val keyringInput = CreateAwsKmsMultiKeyringInput.builder().generator(keyArn).build()
val kmsKeyring = materialProviders.CreateAwsKmsMultiKeyring(keyringInput)
暗号化処理
-
平文文字列をバイト配列に変換
暗号化対象の文字列をUTF-8のバイト配列に変換します。
Encryption SDKはバイナリデータを入力として扱うため、この形式に変換しておきます。 -
暗号化時のencryption contextを設定
暗号化処理に付与するメタデータとしてコンテキスト(encryption context)を設定します。
これは「どの目的で暗号化されたデータか」を識別するための情報で、復号時にも同じ内容を指定する必要があります。 -
encryptDataによって暗号化を実行
SDKのencryptDataを呼び出すことで、DEKにより平文データを暗号化します。
結果として、暗号化済みデータと暗号化済みDEKを含むバイナリを返します。
def encrypt(plainText: String, context: String) : Future[Either[EncryptionError, Array[Byte]]] = {
val targetText = plainText.getBytes(StandardCharsets.UTF_8)
val encryptionContext = Map("ContextKey" -> context).asJava
Future(crypto.encryptData(kmsKeyring, targetText, encryptionContext))
.attemptT
.bimap(EncryptError, _.getResult)
.value
}
復号処理
-
encryption contextの設定
暗号化時に使用したコンテキスト(encryption context)を同じ内容で設定します。
コンテキストが一致しない場合、検証が通らず復号は失敗します。 -
復号の実行
暗号化済みのデータを渡すと、内部的に暗号化済みのDEKがKMSによって復号され、
復号されたDEKを用いてデータ本体が復号されます。 -
文字列へ変換
復号されたデータはArray[Byte]形式で返されるため、UTF-8文字列に変換して平文として取得します。
def decrypt(cipherArrayByte: Array[Byte], context: String): Future[Either[EncryptionError, String]] = {
val encryptionContext = Map("ContextKey" -> context).asJava
Future(crypto.decryptData(kmsKeyring, cipherArrayByte, encryptionContext))
.attemptT
.bimap(DecryptError, result => new String(result.getResult, StandardCharsets.UTF_8))
.value
}
- 最終構成
ここまで分割して解説した内容をひとつにまとめると、EncryptionService全体は以下のようになります。
@Singleton
case class EncryptionService @Inject() (configuration: Configuration) (implicit executionContext: ExecutionContext) {
sealed trait EncryptionError {
def message: String
def cause: Throwable
}
final case class DecryptError(cause: Throwable) extends EncryptionError {
val message = "decrypt failed"
}
final case class EncryptError(cause: Throwable) extends EncryptionError {
val message = "encrypt failed"
}
val keyArn = configuration
.get[String]("aws.keyArn")
val crypto = AwsCrypto.builder.withCommitmentPolicy(CommitmentPolicy.RequireEncryptRequireDecrypt).build
val materialProviders = MaterialProviders.builder.MaterialProvidersConfig(MaterialProvidersConfig.builder.build).build
val keyringInput = CreateAwsKmsMultiKeyringInput.builder().generator(keyArn).build()
val kmsKeyring = materialProviders.CreateAwsKmsMultiKeyring(keyringInput)
def encrypt(plainText: String, context: String) : Future[Either[EncryptionError, Array[Byte]]] = {
val targetText = plainText.getBytes(StandardCharsets.UTF_8)
val encryptionContext = Map("ContextKey" -> context).asJava
Future(crypto.encryptData(kmsKeyring, targetText, encryptionContext))
.attemptT
.bimap(EncryptError, _.getResult)
.value
}
def decrypt(cipherArrayByte: Array[Byte], context: String): Future[Either[EncryptionError, String]] = {
val encryptionContext = Map("ContextKey" -> context).asJava
Future(crypto.decryptData(kmsKeyring, cipherArrayByte, encryptionContext))
.attemptT
.bimap(DecryptError, result => new String(result.getResult, StandardCharsets.UTF_8))
.value
}
}
コントローラー
この章では、先ほど実装した EncryptionService を利用して、
リクエストデータの暗号化およびレスポンスデータの復号を行います。
暗号化
createアクションでは、リクエストで受け取ったplainTextを暗号化します。
コンテキストには、リクエストを受け取った時点の日時を設定します。
Todoを生成し、データベースに保存します。
def create(): Action[JsValue] = Action(parse.json).async { implicit request =>
(for {
payload <- EitherT.fromEither[Future](request.body.validate[JsValueTodo].asEither.leftMap(_ => BadRequest))
now = LocalDateTime.now()
bytes <- encryptPlainText(payload.plainText, now)
todo = Todo.build(bytes, now)
_ <- EitherT.right[Result](todoDao.insert(todo))
} yield NoContent).merge
}
private def encryptPlainText(plainText: String, now: LocalDateTime): EitherT[Future, Result, Array[Byte]] =
EitherT(
encryptionService
.encrypt(plainText, now.toString)
).leftMap(err => InternalServerError(err.message))
復号
listアクションでは、保存されているTodoを取得し、その中の暗号化済みcipherBytesを復号します。
復号時には、暗号化時に設定したコンテキスト(作成日時)を指定します。
復号後の内容をJsValueWriteTodoに変換してJSON形式で返します。
def list() = Action.async {
(for {
todos <- EitherT.right[Result](todoDao.all())
result <- todos.toVector.traverse(decryptTodo)
} yield {
Ok(Json.toJson(result))
}).merge
}
private def decryptTodo(todo: Todo): EitherT[Future, Result, JsValueWriteTodo] =
EitherT(encryptionService.decrypt(todo.cipherBytes, todo.createdAt.toString))
.bimap(err => InternalServerError(err.message), plainText => JsValueWriteTodo.fromModel(todo, plainText))
KMSキー(KEK)作成
暗号化処理で使用するKEKを作成します。
ここでは、AWSマネジメントコンソールから新しいキーを作成した際の設定例を示します。
項目 設定値
| 項目 | 設定値 |
|---|---|
| キーのタイプ | 対称 |
| キーの使用法 | 暗号化および復号 |
| キーマテリアルオリジン | KMS |
| リージョン | 単一リージョン |
| エイリアス | localtest |
| キーの管理者 | 管理できるユーザーを指定 |
| キーユーザー | 指定なし |
| キーポリシー | デフォルト(編集なし) |
デフォルトのキーポリシーについて
デフォルトのキーポリシーには、以下のようなステートメントが含まれています。
このステートメントではアカウントプリンシパル(root)が指定されており、この設定によりIAMポリシーでの制御が可能になります。
"Statement": [
{
"Sid": "Enable IAM User Permissions",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::xxxx:root"
},
"Action": "kms:*",
"Resource": "*"
},
]
キーポリシーステートメントのプリンシパルがアカウントプリンシパルである場合、ポリシーステートメントはどの IAM プリンシパルに対しても、KMS キーを使用する許可を付与しません。代わりにアカウントに IAM ポリシーを使用して、ポリシーステートメントで指定された許可を委譲することができます。このデフォルトのキーポリシーステートメントにより、アカウントは IAM ポリシーを使用して、KMS キーに対するすべてのアクション (kms:*) の許可を委譲することができます。
後の工程でこのキーのARNを使用するため、キーARN(KMSキーのARN)を控えておきます。
ローカル実行用IAM設定
ローカル環境で暗号化・復号を行うため、最小限のIAM設定を行います。
1. IAM ユーザー作成
まず、ローカル検証用のIAMユーザーを作成します。
ユーザーグループ名:kms-test-user-group
ユーザー名:kms-test-user
後の工程でこのユーザーの認証情報を使用するため、アクセスキーIDとシークレットアクセスキーを発行し、控えておきます。
2. ローカル検証用ポリシー作成
暗号化/復号に必要なアクションを許可するポリシーを作成します。
Resourceには、KMSキー作成時にメモしたキーARNを記載します。
- ポリシー名:kms-test-policy
{
"Version": "2012-10-17",
"Statement": {
"Effect": "Allow",
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": [
"arn:aws:kms:ap-northeast-1:xxxx:key/xxxx"
]
}
}
3. IAM Role作成
次に、KMSキーを使って暗号化・復号を行えるIAMロールを作成します。
このロールはローカル環境で利用するため、kms-test-userがAssumeRoleできるように設定します。
「信頼されたエンティティ」に以下のカスタム信頼ポリシーを設定します。
後の工程でこのロールのARNを使用するため、ARNを控えておきます。
先ほど作成したkms-test-policyをアタッチします。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::xxxx:user/kms-test-user"
},
"Action": "sts:AssumeRole"
}
]
}
4. AssumeRole用 IAM ポリシー作成
最後に、kms-test-userがこのロールを引き受け(AssumeRole)できるように、ユーザー側にも以下のポリシーを付与します。
このポリシーはユーザーにアタッチします。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "kmsTestAssumeRolePolicy",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::xxxx:role/kms-test-role"
}
]
}
環境変数設定
AWS CLIやSDKがKMSを利用できるようにするため、作成したIAMユーザーのアクセスキー情報とAssumeRoleで得た一時認証情報を環境変数に設定します。
- クレデンシャル設定(IAMユーザー)
まず、作成したIAMユーザー(kms-test-user)のアクセスキーIDとシークレットアクセスキーを設定します。
export AWS_ACCESS_KEY_ID=<発行したアクセスキーID>
export AWS_SECRET_ACCESS_KEY=<発行したシークレットアクセスキー>
Role引き受け
次に、IAMユーザーとしてロールを引き受け、一時的なセッションを発行します。
以下のコマンドを実行します。
aws sts assume-role --role-arn "arn:aws:iam::xxxx:role/kms-test-role" --role-session-name AWSCLI-Session
コマンドのレスポンスには以下のようなJSONが返されます
{
"Credentials": {
"AccessKeyId": "ASIA....EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"SessionToken": "FwoGZXIvYXdzEKr...<省略>...",
"Expiration": "2025-11-11T09:00:00Z"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROAXXXXXXX:AWSCLI-Session",
"Arn": "arn:aws:sts::xxxx:assumed-role/kms-test-role/AWSCLI-Session"
}
}
- 一時認証情報の環境変数設定
返ってきたAccessKeyId、SecretAccessKey、SessionTokenを環境変数に設定します。
AWS_KEY_ARNには、KMSキー作成時にメモしたキーARNを指定します。
export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPy...truncated...zrkuWJOgQs8IZZaIv2BXIa2R4Olgk
export AWS_REGION=ap-northeast-1
export AWS_KEY_ARN=arn:aws:kms:ap-northeast-1:xxxx:key/xxxx
これで、AWS Encryption SDKがKMSへアクセスできるようになります。
検証
アプリケーションの起動
アプリケーションを起動します。
デフォルトでは localhost:9000 でサーバーが立ち上がります。
sbt run
H2 Consoleの起動
H2 Consoleでデータベースの状態を確認するために以下コマンドを実行します。
sbt
h2-browser
ブラウザが開いたら、以下の情報を入力してログインします。
- JDBC URL: jdbc:
h2:file:./data/playdb;MODE=MYSQL;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=TRUE - ユーザ名: sa
- パスワード: なし

1. 暗号化
以下のリクエストでTodoを追加します。
curl --location 'http://localhost:9000/todo' \
--header 'Content-Type: application/json' \
--data '{"plainText":"todo!"}'
リクエストが成功したら、H2 Consoleで次のクエリを実行します。
SELECT * FROM TODO

cipher_bytesカラムに暗号文(メッセージバイト列)が保存されていることを確認できます。
2. 復号
次に、Todo一覧を取得します。
curl --location --request GET 'http://localhost:9000/todo'
APIレスポンスでは、暗号化されていたデータが復号されて返ってくることを確認できます。
まとめ
KMSを利用することで、アプリケーションは鍵管理を意識することなく安全に暗号化を実現できます。
また、KMSが直接データを暗号化するのではなく、DEKを暗号化して管理する仕組みがとくに印象的でした。
この構造により、KMSの呼び出しコストを抑えながら、大量のデータを効率的に扱える点が非常に優れていると感じました。
Discussion