EncryptedSharedPreferencesからTink + DataStoreに置き換える

EncryptedSharedPreferencesからTink + DataStoreに置き換えた話
こんにちは。Toyota Woven City Payment 開発グループの大杉です。
私たちのチームでは、 Woven by ToyotaのToyota Woven City で使用される決済システムの開発をしており、バックエンドからWebフロントエンド、そして、モバイルアプリケーションまで決済関連の機能を幅広く担当しています。
今回は公式にDeprecatedになってしまったEncryptedSharedPreferencesを実装していたAndroidアプリの置き換えをした話をまとめました。
はじめに
EncryptedSharedPreferencesがv1.1.0-alpha07からDeprecatedになり、Android KeyStoreへの置き換えが公式から推奨されました。
EncryptedSharedPreferencesの代替技術調査
EncryptedSharedPreferencesがDeprecatedとなったことで、永続化手段と暗号化技術の調査を始めました。
永続化手段の選定
私たちのアプリにおけるユースケースでは、設定データの保存にEncryptedSharedPreferencesを使用していただけであったので、SharedPreferencesを使用するだけでも十分ではありました。
ですが、せっかくの置き換えタイミングであったので公式推奨に則り、永続化手段としてDataStoreを採用しました。
暗号ライブラリの選定
こちらも前述の公式推奨の通り、Android KeyStoreを使用する方針で進めていこうとしたのですが、APIレベルによって機能の制約があるだけでなく、セキュリティレベルの高い実装(StrongBox)を使用するにはデバイスのスペックも関係するため、単純にプログラミングするだけでは想定したセキュリティレベルを担保できない可能性もありました。
今回のアプリは、MDMで管理されたデバイス上で動作する前提であり、StrongBoxにも対応しているデバイスを元々選定していたため、この制約については問題になりませんでした。
また、暗号ライブラリ調査の中で、TinkというGoogleが提供している暗号ライブラリの存在を知りました。
Tinkのリポジトリを見ると、マスターキーの保存にAndroid KeyStoreを利用されていることがわかります。
メンテナンスの容易さやパフォーマンスの観点でAndroid KeyStoreとTinkを比較するため、サンプル実装を行いました。
暗号ライブラリの実装比較
Android KeyStoreのStrongBoxとTEEを使用した場合とTinkを使用した場合のサンプルコードを以下にまとめています。
両者とも基本的な実装はそこまで苦労せず着手できたと感じました。
一方で、Android KeyStoreは
- 暗号アルゴリズムによってAndroid KeyStoreの鍵発行設定を変える必要がある
- 初期化ベクトル(IV)の管理が開発者依存になる
- 実装サンプルが少ない
Tinkはこの辺りをうまくラップしてくれている良さがあります。
Android KeyStoreを使用した暗号・復号処理の実装例
class AndroidKeyStoreClient(
private val useStrongKeyBox: Boolean = false
) {
private val keyStoreAlias = "key_store_alias"
private val KEY_STORE_PROVIDER = "AndroidKeyStore"
private val keyStore by lazy {
KeyStore.getInstance(KEY_STORE_PROVIDER).apply {
load(null)
}
}
private val cipher by lazy {
Cipher.getInstance("AES/GCM/NoPadding")
}
private fun generateSecretKey(): SecretKey {
val keyStore = keyStore.getEntry(keyStoreAlias, null)
if (keyStore != null) {
return (keyStore as KeyStore.SecretKeyEntry).secretKey
}
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE_PROVIDER)
.apply {
init(
KeyGenParameterSpec.Builder(
keyStoreAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setIsStrongBoxBacked(useStrongKeyBox)
.setKeySize(256)
.build()
)
}.generateKey()
}
fun encrypt(inputByteArray: ByteArray): Result<String> {
return runCatching {
val secretKey = generateSecretKey().getOrThrow()
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encryptedData = cipher.doFinal(inputByteArray)
cipher.iv.joinToString("|") + ":iv:" + encryptedData.joinToString("|")
}
}
fun decrypt(inputEncryptedString: String): Result<ByteArray> {
return runCatching {
val (ivString, encryptedString) = inputEncryptedString.split(":iv:", limit = 2)
val iv = ivString.split("|").map { it.toByte() }.toByteArray()
val encryptedData = encryptedString.split("|").map { it.toByte() }.toByteArray()
val secretKey = generateSecretKey()
val gcmParameterSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec)
cipher.doFinal(encryptedData)
}
}
}
Tinkを使用した暗号・復号処理の実装例
class TinkClient(
context: Context
) {
val keysetName = "key_set"
val prefFileName = "pref_file"
val packageName = context.packageName
var aead: Aead
init {
AeadConfig.register()
aead = buildAead(context)
}
private fun buildAead(context: Context): Aead {
return AndroidKeysetManager.Builder()
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withSharedPref(
context,
"$packageName.$keysetName",
"$packageName.$prefFileName"
)
.withMasterKeyUri("android-keystore://tink_master_key")
.build()
.keysetHandle
.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
fun encrypt(inputByteArray: ByteArray): Result<String> {
return runCatching {
val encrypted = aead.encrypt(inputByteArray, null)
Base64.getEncoder().encodeToString(encrypted)
}
}
fun decrypt(inputEncryptedString: String): Result<ByteArray> {
return runCatching {
val encrypted = Base64.getDecoder().decode(inputEncryptedString)
aead.decrypt(encrypted, null)
}
}
}
暗号ライブラリのパフォーマンス検証
Android KeyStoreとTinkの暗号化処理時間のベンチマークを計測しました。
Android KeyStoreについては、StrongBoxとTEEの2つの実行基盤を利用したケースで評価しています。
テストコードでは、共通の暗号化アルゴリズム(AES_GCM)を設定し、10KBのデータを繰り返し暗号化する処理をMicrobenchmarkを使用して計測しました。Microbenchmarkを使用することで、Google Pixel Tabletの実機上でかつUIスレッド以外のスレッドを利用して計測を行っています。
テストコードは以下です。
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ExampleBenchmark {
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun benchmarkTinkEncrypt() {
val context = InstrumentationRegistry.getInstrumentation().context
val client = TinkClient(context)
val plainText = ByteArray(1024 * 10)
benchmarkRule.measureRepeated {
client.encrypt(plainText).getOrThrow()
}
}
@Test
fun benchmarkStrongBoxEncrypt() {
val context = InstrumentationRegistry.getInstrumentation().context
val client = AndroidKeyStoreClient(context, true)
val plainText = ByteArray(1024 * 10)
benchmarkRule.measureRepeated {
client.encrypt(plainText).getOrThrow()
}
}
@Test
fun benchmarkTeeEncrypt() {
val context = InstrumentationRegistry.getInstrumentation().context
val client = AndroidKeyStoreClient(context, false)
val plainText = ByteArray(1024 * 10)
benchmarkRule.measureRepeated {
client.encrypt(plainText).getOrThrow()
}
}
}
以下に計測結果をまとめました。
暗号化基盤 | 平均処暗号理時間 (ms) | アロケーション数 |
---|---|---|
Android KeyStore (StrongBox) | 209 | 4646 |
Android KeyStore (TEE) | 7.07 | 4786 |
Tink | 0.573 | 38 |
Android KeyStore (StrongBox)およびAndroid KeyStore (TEE)ではハードウェアへのアクセスが発生するため、ソフトウェア側で暗号化処理を行っているTinkと比べてかなり処理に時間がかかっていることがわかります。
今回採用したデバイスはAndroidの中でも比較的スペックが高いものですが、特にAndroid KeyStore (StrongBox)を採用する場合は、UXの検討が必要になりそうです。
備考
ちなみに、実際にAndroid KeyStoreの鍵生成で適用されている実行基盤は以下のコードから判別できます。
val secretKey = generateSecretKey()
val kf = SecretKeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE_PROVIDER)
val ki = kf.getKeySpec(secretKey, KeyInfo::class.java) as KeyInfo
val securityLevelString = when (ki.securityLevel) {
KeyProperties.SECURITY_LEVEL_STRONGBOX -> "STRONGBOX"
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE"
KeyProperties.SECURITY_LEVEL_SOFTWARE -> "SOFTWARE"
else -> "UNKNOWN"
}
Log.d("KeyStoreSecurityLevel", "Security Level: ${ki.securityLevel}")
まとめ
EncryptedSharedPreferencesがDeprecatedとなったため、移植先の技術選定を行いました。
公式推奨に則り、永続化手段としてDataStoreを採用しました。
暗号化技術に関してはAndroid KeyStoreとTinkの比較検証を行い、Tinkの方が鍵の発行や暗号化処理が抽象化されていて利用しやすく、また、処理速度も優れていることがわかり、セキュリティ要件としても十分であるためTinkを採用することにしました。
Android KeyStoreを採用する場合は、動作環境のデバイススペックも考慮した実装が求められるため、セキュリティ要件とのバランスを考慮する必要がありそうです。
関連記事 | Related Posts
We are hiring!
【プロダクト開発バックエンドエンジニア(リーダークラス)】共通サービス開発G/東京・大阪
共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。
【フロントエンドエンジニア(リードクラス)】プロジェクト推進G/東京
配属グループについて▶新サービス開発部 プロジェクト推進グループ 中古車サブスク開発チームTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 中古車 』のWebサイトの開発、運用を中心に、その他サービスの開発、運用も行っています。