2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EncryptedSharedPreferences → DataStore + Tink 移行したお話

2
Last updated at Posted at 2025-10-08

総括: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アプリ設計パターンをちゃんと意識して開発・保守する大切さを痛感します。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?