🍣

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で保護するという二段階構造の仕組みです。

暗号化の流れ

  1. KMSからデータ暗号化用の鍵(DEK)を生成する
    • KMSは平文のDEKと、KEKで暗号化したDEKの両方を返す
  2. クライアントは平文のDEKを使って対象データを暗号化する
  3. KMSから返された暗号化済みDEKと暗号化データをセットで保存する

復号の流れ

  1. 暗号化データと一緒に保存していた暗号化済みDEKをKMSに渡し、KMSキー(KEK)で復号する
  2. KMSから返された平文のDEKを使って、クライアント側で暗号化済みデータを復号する

シーケンス図

https://pages.awscloud.com/rs/112-TZM-766/images/AWS-Black-Belt_2024_AWS_KeyManagementService_0331_v1.pdf

なぜエンベロープ暗号を使うのか

KEKで直接暗号化できるデータは最大4KBまでという制限があります。
そのため、アプリケーション側で大容量データをDEKで暗号化し、そのDEKをKMSで保護する構成を取ることで、大容量のデータを暗号化できます。

Encrypts plaintext of up to 4,096 bytes using a KMS key

https://docs.aws.amazon.com/ja_jp/kms/latest/APIReference/API_Encrypt.html

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

https://docs.aws.amazon.com/ja_jp/encryption-sdk/latest/developer-guide/java.html

また、今回は簡易的な検証のため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.

https://h2database.com/html/features.html?highlight=server&search=server#auto_mixed_mode

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を追加します。

https://www.playframework.com/documentation/3.0.x/Evolutions

-- 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を生成、暗号化、復号します。

https://docs.aws.amazon.com/ja_jp/encryption-sdk/latest/developer-guide/use-kms-keyring.html

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)

暗号化処理

  1. 平文文字列をバイト配列に変換
    暗号化対象の文字列をUTF-8のバイト配列に変換します。
    Encryption SDKはバイナリデータを入力として扱うため、この形式に変換しておきます。

  2. 暗号化時のencryption contextを設定
    暗号化処理に付与するメタデータとしてコンテキスト(encryption context)を設定します。
    これは「どの目的で暗号化されたデータか」を識別するための情報で、復号時にも同じ内容を指定する必要があります。

  3. 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
  }

復号処理

  1. encryption contextの設定
    暗号化時に使用したコンテキスト(encryption context)を同じ内容で設定します。
    コンテキストが一致しない場合、検証が通らず復号は失敗します。

  2. 復号の実行
    暗号化済みのデータを渡すと、内部的に暗号化済みのDEKがKMSによって復号され、
    復号されたDEKを用いてデータ本体が復号されます。

  3. 文字列へ変換
    復号されたデータは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
  }
  1. 最終構成
    ここまで分割して解説した内容をひとつにまとめると、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:*) の許可を委譲することができます。

https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/key-policy-default.html

後の工程でこのキーのARNを使用するため、キーARN(KMSキーのARN)を控えておきます。

ローカル実行用IAM設定

ローカル環境で暗号化・復号を行うため、最小限のIAM設定を行います。

1. IAM ユーザー作成

まず、ローカル検証用のIAMユーザーを作成します。
ユーザーグループ名:kms-test-user-group
ユーザー名:kms-test-user
後の工程でこのユーザーの認証情報を使用するため、アクセスキーIDとシークレットアクセスキーを発行し、控えておきます。

2. ローカル検証用ポリシー作成

暗号化/復号に必要なアクションを許可するポリシーを作成します。
Resourceには、KMSキー作成時にメモしたキーARNを記載します。

  • ポリシー名:kms-test-policy

https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/cmks-in-iam-policies.html

{
  "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で得た一時認証情報を環境変数に設定します。

  1. クレデンシャル設定(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"
  }
}
  1. 一時認証情報の環境変数設定
    返ってきた AccessKeyIdSecretAccessKeySessionTokenを環境変数に設定します。
    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

https://docs.aws.amazon.com/sdkref/latest/guide/environment-variables.html

これで、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の呼び出しコストを抑えながら、大量のデータを効率的に扱える点が非常に優れていると感じました。

nextbeat Tech Blog

Discussion