SwiftUIのKeychain相当
文字列の暗号化保存に対応
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec 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 import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch /** * Android Keystoreで生成・管理される鍵を使い、 * AES/256/GCM/NoPadding で文字列を暗号化/復号するユーティリティ。 * * androidx.security:security-crypto の EncryptedSharedPreferences は * 非推奨(開発終了)となったため、Keystoreを直接利用する方式に置き換える。 * * ポイント: * - 鍵の実体はKeystore内(可能な端末ではTEE/StrongBoxなどのセキュアハードウェア)に留まり、 * アプリコード側には一切出てこない * - 暗号文だけを通常のストレージ(DataStore等)に保存すればよい * - minSdk 23 (Android 6.0) 以上が前提 */ object KeyCryptoManager { private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val KEY_ALIAS = "my_app_secret_key" private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val GCM_TAG_LENGTH_BITS = 128 private const val GCM_IV_LENGTH_BYTES = 12 private val keyStore: KeyStore by lazy { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } } /** Keystoreに鍵が無ければ生成し、あれば既存の鍵を取得する */ private fun getOrCreateSecretKey(): SecretKey { keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE ) val spec = KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) // 生体認証/端末認証をしないと使えない鍵にしたい場合は以下を有効化 // .setUserAuthenticationRequired(true) // 対応端末ではStrongBox(専用セキュリティチップ)を強制したい場合は以下も検討 // .setIsStrongBoxBacked(true) .build() keyGenerator.init(spec) return keyGenerator.generateKey() } /** * 平文文字列を暗号化し、Base64文字列として返す。 * GCMのIV(初期化ベクトル)は毎回ランダムに生成され、暗号文の先頭に連結して保存するため * 呼び出し側でIVを別途管理する必要はない。 */ fun encrypt(plainText: String): String { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) val iv = cipher.iv val cipherBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) val combined = ByteArray(iv.size + cipherBytes.size) System.arraycopy(iv, 0, combined, 0, iv.size) System.arraycopy(cipherBytes, 0, combined, iv.size, cipherBytes.size) return Base64.encodeToString(combined, Base64.NO_WRAP) } /** * encrypt() で生成されたBase64文字列を復号して平文に戻す。 */ fun decrypt(encryptedBase64: String): String { val combined = Base64.decode(encryptedBase64, Base64.NO_WRAP) val iv = combined.copyOfRange(0, GCM_IV_LENGTH_BYTES) val cipherBytes = combined.copyOfRange(GCM_IV_LENGTH_BYTES, combined.size) val cipher = Cipher.getInstance(TRANSFORMATION) val spec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), spec) val decryptedBytes = cipher.doFinal(cipherBytes) return String(decryptedBytes, Charsets.UTF_8) } /** ロジック変更やログアウト時などに鍵ごと破棄したい場合に使用 */ fun deleteKey() { if (keyStore.containsAlias(KEY_ALIAS)) { keyStore.deleteEntry(KEY_ALIAS) } } } // build.gradle.kts に以下の依存関係を追加してください: // implementation("androidx.datastore:datastore-preferences:1.1.1") private val Context.secureDataStore: DataStore<Preferences> by preferencesDataStore(name = "secure_prefs") /** * DataStoreには「暗号化済みの文字列(Base64)」だけを保存するリポジトリ。 * 暗号化/復号は全てKeyCryptoManager(Android Keystore)側で行うため、 * ここで平文を扱うことは一切ない。 * * SharedPreferences自体もGoogleはDataStoreへの移行を推奨しているため、 * EncryptedSharedPreferencesの単純な置き換え先としてDataStoreを採用している。 */ class SecureStringStore(private val context: Context) { private fun keyFor(name: String) = stringPreferencesKey(name) /** 文字列を暗号化してから保存する */ suspend fun save(key: String, plainText: String) { val encrypted = KeyCryptoManager.encrypt(plainText) context.secureDataStore.edit { prefs -> prefs[keyFor(key)] = encrypted } } /** 保存値を復号した状態でFlowとして監視する */ fun observe(key: String): Flow<String?> { return context.secureDataStore.data.map { prefs -> prefs[keyFor(key)]?.let { KeyCryptoManager.decrypt(it) } } } suspend fun remove(key: String) { context.secureDataStore.edit { prefs -> prefs.remove(keyFor(key)) } } } private const val SECRET_KEY = "secret_value" /** SecureStringStoreをラップし、Compose画面へStateとして公開するViewModel */ class SecureStringViewModel(application: Application) : AndroidViewModel(application) { private val store = SecureStringStore(application) private val _savedValue = MutableStateFlow<String?>(null) val savedValue: StateFlow<String?> = _savedValue init { store.observe(SECRET_KEY) .onEach { _savedValue.value = it } .launchIn(viewModelScope) } fun save(plainText: String) { viewModelScope.launch { store.save(SECRET_KEY, plainText) } } fun clear() { viewModelScope.launch { store.remove(SECRET_KEY) } } } |