総括:DataStore API でも暗号化をサポートして欲しい!
背景
保守管理しているレガシーアプリのデータレイヤーを刷新する機会がありまして、データの永続化+暗号化の部分をどうするか悩んでおりました...
EncryptedSharedPreferences は非推奨
定番の androidx.security:security-crypto:* ですが、1.1.0-alpha07 より非推奨となる動きが見られました。
API の変更
- 既存のプラットフォーム API と Android Keystore の直接使用を優先して、すべての API を非推奨にしました
遂に2025年7月末には安定版 1.1.0 が公開され、非推奨化&サポート終了が宣言されています。
警告: security-crypto および security-crypto-ktx ライブラリはサポートが終了しました。以降のバージョンはリリースされません。詳細については、リリースノートと非推奨のドキュメントをご覧ください。
技術選定
EncryptedSharedPreferencesの代替として、保存処理は公式が推奨する DataStore API を使うとして、暗号化処理は以下のような候補を考えました。
| 代替案 | 保守性 | 信頼性 | 難易度 |
|---|---|---|---|
| 独自実装 + KeyStore API 直接使う | ❌️ 自分で頑張る |
⭕️ 自己責任 |
❌️ やや煩雑 |
| 3rdパーティ製のライブラリ | ? メンテナー依存 |
? メンテナー依存 |
⭕️ シンプルな設計が多そう |
| Google Tink ライブラリ | ⭕️ Googleを信じる |
⭕️ security-crypto の内部で使用されていた |
⭕️ 使いやすく抽象化されている |
それぞれ利点・懸念がありますが、Google を信じて Tink を採用しました。
実装例
暗号化・復号の実装
TinkはAndroid向けにも用意されています。
dependencies {
implementation 'com.google.crypto.tink:tink-android:1.17.0'
}
最初にプリミティブ(暗号化の方法)に応じたインスタンスの取得が必要です。ここでは一般に推奨される Authenticated Encryption with Associated Data (AEAD) を選択しています。鍵をハードウェアレベルで安全に管理するため、プリミティブの初期化だけAndroid固有の実装が登場します。
@Singleton
class MyCipher @Inject constructor(
@ApplicationContext
private val context: Context,
) {
private val aead: Aead
init {
// Tink初期化(AEAD実装のみ)
AeadConfig.register()
// Android専用の鍵管理
val keysetManager = AndroidKeysetManager.Builder()
// MasterKeyで暗号化したkeysetはSharedPreferencesに保存される
.withSharedPref(context, "keyset", "encrypted_keyset")
// 暗号化のアルゴリズムを指定
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
// MasterKeyはKeyStoreを使う
// android-keystoreスキーマ:Android KeyStore を指定(固定)
// tink_master_key:MasterKeyのalias(自由)
.withMasterKeyUri("android-keystore://tink_master_key")
.build()
// プリミティブの取得
aead = keysetManager.keysetHandle.getPrimitive(
RegistryConfiguration.get(),
Aead::class.java,
)
}
}
あとは暗号化・復号処理を追加するだけです。Tinkがいい感じに抽象化してくれるので、プラットフォームを意識せず実装できるのが嬉しいです。
@Singleton
class MyCipher @Inject constructor(
@ApplicationContext
private val context: Context,
) {
private val aead: Aead
+ /**
+ * @param value 暗号化するデータ
+ * @param associatedData 認証に使用するデータ(暗号化されない)
+ * 同じ平文でも異なる暗号文を生成できるため
+ * @return 暗号化されたデータ
+ */
+ fun encrypt(value: ByteArray, associatedData: ByteArray?): ByteArray {
+ return aead.encrypt(value, associatedData)
+ }
+
+ /**
+ * @param value 復号する暗号化されたデータ
+ * @param associatedData 暗号化時と同じ認証データ
+ * @return 復号されたデータ
+ */
+ fun decrypt(value: ByteArray, associatedData: ByteArray?): ByteArray {
+ return aead.decrypt(value, associatedData)
+ }
}
非確定的なプリミティブ
AEADを含む非確定的なプリミティブでは、同じ平文でも異なる認証用データに対して異なるデータに暗号化されます。データの性質にも依存しますが、頻度分析攻撃に強い特徴があります。
(例)Boolean型のデータを「yes」「no」として暗号化するとき、確定的なプリミティブでは暗号化後のデータが2種類しか存在しません。観測される頻度から平文を推測することが容易に可能です。
DataStore との統合
EncryptedSharedPreferencesからの移行を前提として、Preferences DataStore を使います。
残念ながら Preferences クラスはライブラリ外でインスタンス化できない(コンストラクタがinternal)ため、同等のインターフェイスを備えた薄いラッパーを用意します。
open class SecurePreferences(
private val cipher: MyCipher,
private val preferences: Preferences,
) {
companion object {
// Associated Dataを使用した暗号化用の定数
const val ASSOCIATED_DATA_LENGTH = 16
}
operator fun contains(key: Preferences.Key<String>): Boolean = key in preferences
operator fun get(key: Preferences.Key<String>): String? {
val data = preferences[key] ?: return null
val decoded = Base64.getDecoder().decode(data)
// 先頭16バイトが associated data、残りが暗号化データ
require(decoded.size > ASSOCIATED_DATA_LENGTH)
val associatedData = decoded.sliceArray(0 until ASSOCIATED_DATA_LENGTH)
val encryptedData = decoded.sliceArray(ASSOCIATED_DATA_LENGTH until decoded.size)
val decryptedValue = cipher.decrypt(encryptedData, associatedData)
return String(decryptedValue, Charsets.UTF_8)
}
fun toMutable() = MutableSecurePreferences(
cipher = cipher,
preferences = preferences.toMutablePreferences(),
)
fun toPreferences() = preferences.toPreferences()
}
class MutableSecurePreferences(
private val cipher: MyCipher,
private val preferences: MutablePreferences,
) : SecurePreferences(cipher, preferences) {
private val random = SecureRandom()
operator fun set(key: Preferences.Key<String>, value: String) {
// ランダムなassociated dataを生成(16バイト)
val associatedData = ByteArray(ASSOCIATED_DATA_LENGTH)
random.nextBytes(associatedData)
val plaintext = value.toByteArray(Charsets.UTF_8)
// associated dataを使って暗号化
val encryptedValue = cipher.encrypt(plaintext, associatedData)
// 先頭16バイトにassociated data、その後に暗号化データを配置
val combinedData = associatedData + encryptedValue
val encodedData = Base64.getEncoder().encodeToString(combinedData)
preferences[key] = encodedData
}
fun clear() {
preferences.clear()
}
}
認証用データの保存
非確定的プリミティブで使用する認証用データは、それ自体は暗号化されません。しかし暗号化と復号で同じデータを使用する必要があるため、今回は認証用データを固定長とし、暗号化されたデータと一緒に保存しています。
認証用データの取得
非確定的プリミティブを正しく活用するには、暗号化毎に異なる認証用データを使う必要があります。
最後に DataStore の取得と更新方法を実装します。
fun securePreferencesDataStore(
cipher: MyCipher,
name: String,
) = object : ReadOnlyProperty<Context, DataStore<SecurePreferences>> {
private val Context.original by preferencesDataStore(name)
override fun getValue(
thisRef: Context,
property: KProperty<*>,
): DataStore<SecurePreferences> {
val original = with(thisRef) { original }
return object : DataStore<SecurePreferences> {
override val data: Flow<SecurePreferences> =
original.data.map { SecurePreferences(cipher, it) }
override suspend fun updateData(transform: suspend (SecurePreferences) -> SecurePreferences): SecurePreferences {
val transformed = original.updateData {
transform(SecurePreferences(cipher, it)).toPreferences()
}
return SecurePreferences(cipher, transformed)
}
}
}
}
suspend fun DataStore<SecurePreferences>.edit(
transform: suspend (MutableSecurePreferences) -> Unit
): SecurePreferences {
return this.updateData {
it.toMutable().apply {
transform(this)
}
}
}
使用例
DataStore<Preferences> とほぼ同様に扱えます。
class MyStorage @Inject constructor(
@ApplicationContext
private val context: Context,
cipher: MyCipher,
) {
private val Context.dataStore by securePreferencesDataStore(cipher, "my_storage")
val name: Flow<String?> = context.dataStore.data.map {
it[keyName]
}
suspend fun setName(name: String) {
context.dataStore.edit {
it[keyName] = name
}
}
companion object {
private val keyName = stringPreferencesKey("name")
}
}
移行の課題
保守
Tink は破壊的変更がやや多い印象。Androidx のライブラリ群と比べて保守コストは大きいです。
機能
今回は最小限の実装だけ紹介しました。
-
String型のデータ保存しか対応していない - Protocol Buffers には未対応
非同期対応
暗号化実装の趣旨からは外れるのですが、従来の同期的な SharedPreferences とは異なり、Flow + コルーチン実装への対応が必要です。アプリ全体に影響するため実は一番大変でした。最新のAndroidアプリ設計パターンをちゃんと意識して開発・保守する大切さを痛感します。
-
Compose の状態管理のお作法に従ってアプリの状態は全て
Flowで管理する - Lifecycleに対応したコンポーネントを使ってコルーチンを起動する