KINTO Tech Blog
Security

Android で秘匿情報を守る — Keystore, Cipher, DataStore による暗号化と永続化の実装例

Cover Image for Android で秘匿情報を守る — Keystore, Cipher, DataStore による暗号化と永続化の実装例

はじめに

KINTOテクノロジーズの大沼です。
モビリティサービス「my route」アプリの開発に従事しています。

本記事では、AndroidのKeystore、Cipher、DataStoreを使用して秘匿情報の暗号化と永続化を実装した際の実装詳細とハマった点・注意点をまとめました。

こちら大杉さんの記事 では、Tink を使用したケースとパフォーマンス検証を紹介しているのでぜひご一読ください。

💬 実装の前にディスカッション

🔍 本当に暗号化が必要なのか

DroidKaigi 2025のyanzamさんのお話 でも触れられてましたが、Keystore がクラッシュするのは黙認しつつ最低限の機会頻度に抑えたいので、既存で暗号化しているデータが本当に暗号化する必要があるのかの議論をチームメンバーと交わしました。
案の定、不要に暗号化しているものもあり、議論することで最適なものに絞ることができました。

🏗️ アーキテクチャ

セキュリティに関するリファクタリングのコードレビューは、心理負荷が高いと考えています。
私のチームはこういう時、大枠の実装方針を事前に共有し合うことで、コードレビュー時の認識違いや負担が減らせます。

今回、データの暗号化とインターフェースを以下のようなスライドで共有し、大きな齟齬なくレビューを進めることができました。

numa_memo - システムアーキテクチャ.jpg
numa_memo - エラーハンドリング.jpg

🛠️ 実装の流れ

ここからは、実際の実装手順を以下の流れで解説します。

  1. 依存関係の追加 — DataStoreライブラリの導入
  2. Keystoreを使った暗号化キーの生成 — AES/GCMの鍵をAndroid Keystoreで安全に管理
  3. Cipherを使った暗号化・復号化 — 初期化ベクトル(IV)の扱いを含む暗号処理の実装
  4. DataStoreへの保存 — 暗号化したデータをPreferences DataStoreで永続化し、Flowで読み出す

📚 依存関係の追加

ライブラリにDataStoreを追加します。

build.gradle.kts
dependencies {
    // DataStore
    implementation("androidx.datastore:datastore-preferences:1.1.7")
}

🔑 Keystoreを使った暗号化キーの生成

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator

    ...
    
    fun getOrCreateSecretKey(): SecretKey? {
        try {
            // KeyStoreのインスタンス生成
            val keyStore =
                KeyStore.getInstance(ANDROID_KEY_STORE_PROVIDER).apply {
                    load(null) // KeyStoreを初期化するための必須の呼び出し
                }
            
            // KeyStoreにプロダクトの鍵が存在するか確認し、あれば取得し返す
            if (keyStore.containsAlias(PROJECT_KEY_STORE_ALIAS)) {
                val entry = keyStore.getEntry(PROJECT_KEY_STORE_ALIAS, null)
                if (entry is KeyStore.SecretKeyEntry) {
                    return entry.secretKey
                }
            }
            // KeyStoreにプロダクトの鍵が存在しなければ生成して保存し返す
            val params =
                KeyGenParameterSpec.Builder(
                    PROJECT_KEY_STORE_ALIAS,
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
                )
                    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                    .setKeySize(256)
                    .build()
            val keyGenerator =
                KeyGenerator.getInstance(
                    KeyProperties.KEY_ALGORITHM_AES,
                    ANDROID_KEY_STORE_PROVIDER,
                )
            keyGenerator.init(params)
            return keyGenerator.generateKey()
        } catch (e: Exception) {
            Firebase.crashlytics.recordException(e)
            return null
        }
}

🔐 Cipherを使った暗号化・復号化


import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec

interface CryptographyManager {
    fun encrypt(plaintext: String): String

    fun decrypt(encryptedString: String): String
}

private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_SIZE_BYTES = 12
private const val TAG_SIZE_BITS = 128

class CryptographyManagerImpl : CryptographyManager {
    override fun encrypt(plaintext: String): String {
        return try {
            val cipher = Cipher.getInstance(TRANSFORMATION)
            cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
            val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
            val ivAndCiphertext = cipher.iv + ciphertext // IVと暗号文をバイト配列として結合
            Base64.getEncoder().encodeToString(ivAndCiphertext)
        } catch (e: Exception) {
            try {
                CryptographyException.parse(e)
            } catch (cryptoException: CryptographyException) {
                Firebase.crashlytics.recordException(cryptoException)
                ""
            }
        }
    }

    override fun decrypt(encryptedString: String): String {
        return try {
            val cipher = Cipher.getInstance(TRANSFORMATION)
            val ivAndCiphertext = Base64.getDecoder().decode(encryptedString)
            // 復号化時に保存したIVを使う
            val spec =
                GCMParameterSpec(TAG_SIZE_BITS, ivAndCiphertext, 0, IV_SIZE_BYTES)
            cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), spec)
            val plaintext =
                cipher.doFinal(
                    ivAndCiphertext,
                    IV_SIZE_BYTES,
                    ivAndCiphertext.size - IV_SIZE_BYTES,
                )
            String(plaintext, Charsets.UTF_8)
        } catch (e: Exception) {
            try {
                CryptographyException.parse(e)
            } catch (cryptoException: CryptographyException) {
                Firebase.crashlytics.recordException(cryptoException)
                ""
            }
        }
    }
}

💾 DataStoreへの保存

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

data class SecureDataPreferences(
    val textData: String,
)

object PreferencesKeys {
    private val TEXT_KEY = stringPreferencesKey("encrypted_text")
}

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "encrypted_prefs")

class SecureDataRepository(
    private val cryptographyManager: CryptographyManager
) {

    suspend fun saveTextData(data: String) {
        val encryptedData = cryptographyManager.encrypt(data)
        dataStore.edit { preferences ->
            preferences[TEXT_KEY] = encryptedData
        }
    }
    
    private val secureDataFlow: Flow<SecureDataPreferences> =
        secureDataStore.data
            .catch { exception ->
                if (exception is IOException) {
                    emit(emptyPreferences())
                } else {
                    throw exception
                }
            }
            .map {
                it.mapSecureDataPreferences()
            }

    private fun Preferences.mapSecureDataPreferences(): SecureDataPreferences {
        return SecureDataPreferences(
            textData = this[PreferencesKeys.TEXT_KEY]?.let { cryptographyManager.decrypt(it) } ?: "",
            // ... Other data
        )
    }

    suspend fun getTextData(): String {
        return try {
            withTimeout(3000L) {
                secureDataFlow.map { it.textData }.first { it.isNotBlank() }
            }
        } catch (_: TimeoutCancellationException) {
            ""
        } catch (_: NoSuchElementException) {
            ""
        }
    }
}

⚠️ ハマった点・注意点

1. 初期化ベクトル(IV)の保存

暗号化時に生成されるIV(Initialization Vector)は、復号化時に必須です。
IVは暗号文と一緒に保存する必要があります。IVは秘密情報ではないため、平文で保存しても問題ありません。

ハマったポイント: 最初の実装でIVを保存し忘れ、復号化時にjavax.crypto.AEADBadTagExceptionが発生しました。

2. KeyStoreのキーのライフサイクル

Android Keystoreに保存されたキーは、アプリをアンインストールすると削除されます。
また、デバイスのロック画面が解除されるまでキーにアクセスできない設定も可能です(setUserAuthenticationRequired(true))。

注意点: keyが存在しない場合の処理を適切に実装する必要があります。

3. GCMモードのタグ長

GCM(Galois/Counter Mode)を使用する場合、タグ長を正しく設定する必要があります。
一般的には128ビット(16バイト)が使用されます。

4. エラーハンドリング

復号化時にはさまざまなエラーが発生する可能性があります:

  • KeyPermanentlyInvalidatedException: キーが無効化された
  • AEADBadTagException: 暗号文が改ざんされた、またはIVが間違っている
  • InvalidKeyException: キーが無効

これらのエラーをハンドリングし、必要に応じてデータをクリアし再生成するなどの対応が必要です。

5. DataStoreの非同期処理

DataStoreはすべての操作が非同期で行われます。
CoroutineまたはFlowを使用して適切に処理する必要があります。

DataStoreのソースコード内で、最新の値を1つだけ取得できる data.first() を使用することを推奨しています。

// ViewModelでの使用例
viewModelScope.launch {
    repository.saveTextData(sensitiveData)
}

// Flowの監視
repository.secureDataFlow.map { it.textData }.first { it.isNotBlank() }

6. 無限待機の防止

DataStoreはディスクI/Oを伴う非同期処理です。first { it.isNotBlank() } は条件に一致する値が来るまで無限に待機しますが、
もしディスク読み込みが遅延したり、トークンが空のままだと、アプリがフリーズする可能性があり、タイムアウトを追加しました。

7. ProGuard/R8の設定

DataStore 1.1.5以降では、ProGuardルールがライブラリに内包されています。
巷の記事で ProGuardルール の記載が必要なことを目の当たりにしましたが、ルール記載なくリリースビルドしたところクラッシュせず、なぜ?
となっていたところ、リリースノート確認し気づきました。
今回、1.1.7 を使用しているため、DataStore専用のProGuardルールを追加する必要はありません。

https://developer.android.com/jetpack/androidx/releases/datastore

  • バージョン1.2.0-beta01で修正された問題として記載:
    "Fix java.lang.UnsatisfiedLinkError when using DataStore in an app which is optimized with
    R8"
  • バージョン1.1.5で修正:
    "missing Proguard rules issue in the Android artifact of datastore-preferences-core"

8. 標準のSharedPreferencesMigrationが使えない

EncryptedSharedPreferencesは特殊な暗号化を使用していて、 標準のマイグレーションでは暗号化されたままのデータが転送される

また、EncryptedSharedPreferencesは 読み取り時に自動復号化されるのに対し、 今回は CryptographyManagerによる手動暗号化が必要です。
この部分を認知することができず、標準の標準のSharedPreferencesMigrationで実装し、テストしたところ復号できず判明しました。
マイグレーション時に適切な暗号化変換を実装しました。

まとめ

本記事では、Android Keystore、Cipher、DataStoreを組み合わせた秘匿情報の暗号化・永続化の実装について紹介しました。

  • 実装前のディスカッションが重要: そもそも暗号化が必要なデータかをチームで議論することで、Keystoreへのアクセス頻度を最小限に抑えられた
  • Keystoreの鍵管理: AES/GCMモードでの鍵生成とIV(初期化ベクトル)の保存を適切に行う必要がある
  • DataStoreとの組み合わせ: Flowベースの非同期読み出しに対応するため、タイムアウトや無限待機の防止策が必要
  • EncryptedSharedPreferencesからの移行: 標準のSharedPreferencesMigrationでは暗号化方式の違いにより復号できないため、手動でのマイグレーション実装が必要

Keystoreのクラッシュは完全には避けられませんが、暗号化対象を最適化し、エラーハンドリングを適切に実装することで、安定したセキュアなデータ管理を実現できました。

📣 追記: DataStore 1.3.0-alpha07 で暗号化サポートが追加

本記事の執筆後、DataStore 1.3.0-alpha07(2026年3月11日リリース)で、Tinkライブラリを使用した暗号化サポートが新たに追加されました。

新しい androidx.datastore:datastore-tink アーティファクトにより、AeadSerializer を使って既存のシリアライザをラップするだけで暗号化が実現できます。

val aeadSerializer = AeadSerializer(
    aead = keysetHandle.getPrimitive(
        RegistryConfiguration.get(),
        Aead::class.java,
    ),
    wrappedSerializer = ExistingSerializer,
    associatedData = "settings.json".encodeToByteArray(),
)

本記事で紹介したKeystore + Cipher による手動実装と比較すると、Tink統合によりボイラープレートが大幅に削減されます。ただし、現時点ではalpha版であるため、プロダクション導入の際はAPIの安定性を考慮する必要があります。今後のstable版リリースに注目です。

参考資料

Facebook

関連記事 | Related Posts