はじめに
この記事は、Money Forward Engineering 2 Advent Calendar 2022 19日目の投稿です。
18日目は @pocke さんで Rails appをRubyコードの改善だけで50%以上高速にした話でした。
今回はマネーフォワード名古屋開発拠点で日々サーバーサイドKotlin開発をエンジョイしている @keyskey が実践的なアプリケーション開発でよくある、
「テキストデータを暗号化してデータベースに保存する」
という要件にKotlinとjOOQ(とKMS)でチャレンジしてみます。
前提 / 使用ライブラリ
- Kotlin v1.6.10 / Java 17
- SpringBoot v2.6.6 (DIで使うだけ)
- jOOQ v3.17.4 / gradle-jooq-plugin v8.0
- 秘密鍵の生成にはAWS KMSを用いる
- AWS SDK Kotlin v0.16.0
- 暗号化アルゴリズムにはAESを、暗号利用モードにはCBCを用いる (共通鍵暗号アルゴリズムについてはこちらをどうぞ)
- エンベロープ暗号化を行い、鍵自体も暗号化してDBに保存する
なお本記事では説明を割愛しますが、ローカル環境での開発においてはLocalstackを用いてKMSを利用するのが手軽でおすすめです。
ポイント
- 暗号文はBase64エンコーディングしてDBに保存する
- AES-CBCモードは復号化のために暗号化処理で使用された初期化ベクトル(IV)を必要とするため、暗号文自体にIVを含めてDBに保存する
- 暗号文はValue Class化してしまう
- 暗号化処理のコード中では処理中の文字列が平文なのか暗号文なのか分からなくなりがちなので
typealias
を用いて状態を明示することで分かりやすくする
実装
テーブル定義
例としてMySQL上に以下のようなユーザー情報を管理するテーブルを用意し、メールアドレスを暗号化して保存するとします。
ユーザー単位でのエンベロープ暗号化が可能なよう、暗号化された状態の暗号化鍵を保存するカラムも用意しておきます。
create table users
(
id bigint unsigned not null auto_increment,
email text not null,
encryption_key Blob not null,
primary key(id)
);
KMSのラッパーを用意
typealias CipherKey = ByteArray // 暗号化された秘密鍵
typealias PlainKey = ByteArray // 素の秘密鍵
interface KmsAdapter {
suspend fun decryptDataKey(cipherDataKey: CipherKey): PlainKey?
suspend fun generateDataKeyPair(): Pair<CipherKey, PlainKey>
}
@Component
class KmsAdapterImpl : KmsAdapter {
private val logger = LoggerFactory.getLogger(javaClass)
private val kmsKeyId: String = System.getenv("AWS_KMS_KEY_ID")
private val kmsClient: KmsClient = KmsClient {
region = System.getenv("AWS_REGION")
endpointResolver = StaticEndpointResolver(
AwsEndpoint(
url = System.getenv("AWS_KMS_ENDPOINT"),
)
)
}
// 暗号化された秘密鍵から素の秘密鍵を取得する
override suspend fun decryptDataKey(cipherDataKey: CipherKey): PlainKey? {
val decryptKeyRequest = DecryptRequest {
keyId = kmsKeyId
ciphertextBlob = cipherDataKey
}
val result = runCatching {
kmsClient.decrypt(decryptKeyRequest).plaintext
}.onFailure { exception ->
logger.info("Cannot decrypt data key, ${exception.message}")
}
return result.getOrNull()
}
// 素の秘密鍵と暗号化された秘密鍵のペアを生成する
override suspend fun generateDataKeyPair(): Pair<CipherKey, PlainKey> {
val request = GenerateDataKeyRequest {
keyId = kmsKeyId
keySpec = DataKeySpec.fromValue(DataKeySpec.Aes256.value) // 256 bitの秘密鍵を生成する. 128 bitも選べる.
}
val dataKey = kmsClient.generateDataKey(request)
return Pair(dataKey.ciphertextBlob!!, dataKey.plaintext!!)
}
}
KMSから返ってくるエラーの種類はこちらにまとまっています。
AES暗号化用のユーティリティクラスを用意
AES暗号化処理用に javax.crypto.Cipher クラスのラッパーを用意します。
初期化ベクトルを必要とする方式ならいずれも本記事で紹介する実装を大きく変えずに対応可能なはずですが、今回はひとまず定番どころということで暗号利用モードにCBCを、パディング方式としてPKSC #5 を用いてみます。
javax.cryptoパッケージは他にも色々な暗号方式をサポートしています。興味のある方は覗いてみてください。
typealias ClearText = String // 平文
typealias Base64EncodedText = String // AES暗号化で生成されたバイト列をBase64エンコーディングした後の暗号文
// javax.crypto.Cipher クラスのラッパー
class CipherAdapter(val plainKey: PlainKey) {
private val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") // ここで "AES" としか指定しないとデフォルトのECBモードが使われるので注意
private val plainTextKeySpec = SecretKeySpec(plainKey, "AES") // こっちは逆にアルゴリズムの指定だけで良い
// 暗号化処理
fun encrypt(clearText: ClearText): CipherText {
cipher.init(Cipher.ENCRYPT_MODE, plainTextKeySpec)
val iv = cipher.iv // 初期化ベクトルを生成
val cipherBody = cipher.doFinal(clearText.toByteArray())
return CipherText(
iv = iv,
body = cipherBody
)
}
// 復号化処理
fun decrypt(cipherText: CipherText): ClearText {
cipher.init(Cipher.DECRYPT_MODE, plainTextKeySpec, IvParameterSpec(cipherText.iv))
val clearBody = cipher.doFinal(cipherText.body)
return clearBody.toString()
}
}
// AESによって暗号化された暗号文をモデル化したValue Class. 復号処理用に暗号文本体だけでなく初期化ベクトルも保持しておく.
data class CipherText(
val iv: ByteArray, // 初期化ベクトル
val body: ByteArray // 暗号文本体
) {
// データベースに文字列として保存するために初期化ベクトルと暗号文本体を結合してBase64エンコーディングする
fun toDatabase(): Base64EncodedText {
val wholeByteBody = byteArrayOf(*iv, *body)
return Base64.getEncoder().encodeToString(wholeByteBody)
}
companion object {
// AESの初期化ベクトルのバイト長はCBCモードのブロックサイズと同じ16
private const val ivByteSize = 16
// データベースに保存されている暗号文からCipherTextインスタンスを再構成する
fun fromDatabase(text: Base64EncodedText): CipherText {
val wholeByteBody = Base64.getDecoder().decode(text)
val iv = wholeByteBody.sliceArray(1..ivByteSize)
val bodyInitialIndex = ivByteSize + 1
val bodyLastIndex = wholeByteBody.size - 1
val body = wholeByteBody.sliceArray(bodyInitialIndex..bodyLastIndex)
return CipherText(
iv = iv,
body = body
)
}
}
}
DBアクセス用のRepositoryの実装
data class UserEntity(
val id: BigInteger?,
val email: ClearText,
val encryptionKey: CipherKey
)
@Repository
class UserRepository(private val dsl: DSLContext, private val kmsAdapter: KmsAdapter) {
// メールアドレスを暗号化した上でユーザーを永続化する
suspend fun create(user: UserEntity) {
val (encryptedKey, plainKey) = kmsAdapter.generateDataKeyPair()
val encryptedEmail = CipherAdapter(plainKey).encrypt(user.email) // メールアドレスを暗号化
dsl
.insertInto(USER)
.set(USERS.EMAIL, encryptedEmail.toDatabase())
.set(USERS.ENCRYPTION_KEY, encryptedKey)
.execute()
}
// メールアドレスを復号化した上でユーザーを取得する
suspend fun fetchOne(id: BigInteger): UserEntity? {
val record = dsl
.selectFrom(USERS)
.where(USERS.ID.eq(id))
.fetchOne()!!
val plainKey = kmsAdapter.decryptDataKey(record.encryptionKey!!)
return plainKey?.let {
val encryptedEmail = CipherText.fromDatabase(record.email!!)
val decryptedEmail = CipherAdapter(plainKey).decrypt(encryptedEmail) // メールアドレスを復号
record.email = decryptedEmail
record.into(UserEntity::class.java)
}
}
}
サンプルなのでとりあえず暗号化・復号化両方を実装した最低限のメソッドのみ用意しました。
あとはこのRepositoryを使ってユースケースを実現すれば良いという感じです。簡単ですね(?)
感想
Javaは暗号処理用のライブラリが充実しており、AESによる暗号化処理を実装してみた系の記事は調べればたくさん出てくるのですが、いずれもメモリ上でデータを暗号化・復号化しているだけで現実のアプリケーション開発とはギャップがあるものが多いなと感じていました。また最近では秘密鍵の管理にKMSのようなマネージドサービスを使うことも十分一般的になってきているかと思いますが、サーバーサイドJava/Kotlin開発ではSpring Securityを使って鍵を生成することも多く、KMSを使っている実装例はあまり見かけませんでした。
この記事ではなるべく実務における実装イメージが持てるように、かつなるべくイマドキの構成を意識してみたつもりですがいかがでしたでしょうか?
今後新規にサーバーサイドKotlinを用いた開発を行う際に少しでも役に立つ知見になっていれば幸いです。
余談
もしアプリケーション全体、あるいは1テーブル内で単一の秘密鍵を用いることが要件的に許されているのであれば、jOOQのConverterという機能を用いてDB上に暗号化されて保存されているtext型のデータとアプリケーション内で平文で取り回されるString型の値の間での変換を暗黙的に行うことも可能かもしれません。そうするとRepository内で都度暗号化・復号化処理を書く必要もなくなってすごく楽チンです。そのパターンは今回試している時間がなかったので、次回の宿題としたいと思います。