安全存储
源:Android Keystore | iOS Keychain
在处理敏感数据(如用户凭证、API Token、加密密钥)时,普通的 KV 存储是不够的。本文将展示如何利用各平台的安全存储机制保护敏感信息。
平台差异对比
| 平台 | 原生 API | 底层实现 | 关键特性 | 安全级别 |
|---|---|---|---|---|
| Android | EncryptedSharedPreferences + Keystore | AES-256 加密,密钥存储于 TEE/SE | 硬件支持、生物识别绑定 | ⭐⭐⭐⭐⭐ |
| iOS/macOS | Keychain Services | 系统级加密,存储于 Secure Enclave | 自动 iCloud 同步、Touch ID/Face ID | ⭐⭐⭐⭐⭐ |
| Desktop (JVM) | Preferences + 自定义加密 | 需手动实现加密逻辑 | 无硬件支持 | ⭐⭐⭐ |
| Desktop (Native) | OS Credential Manager | Windows Credential Vault / macOS Keychain | 系统级集成 | ⭐⭐⭐⭐ |
expect/actual 实现方案
API 核心签名说明
expect class SecureStoragesuspend fun SecureStorage.saveSecurely(key: String, value: String)suspend fun SecureStorage.getSecurely(key: String): String?suspend fun SecureStorage.removeSecurely(key: String)suspend fun SecureStorage.clearAll()
标准代码块
kotlin
expect class SecureStorage {
suspend fun save(key: String, value: String)
suspend fun get(key: String): String?
suspend fun remove(key: String)
suspend fun clear()
}
// 业务层使用
class AuthRepository(private val secureStorage: SecureStorage) {
suspend fun saveAuthToken(token: String) {
secureStorage.save("auth_token", token)
}
suspend fun getAuthToken(): String? {
return secureStorage.get("auth_token")
}
suspend fun clearSession() {
secureStorage.remove("auth_token")
secureStorage.remove("refresh_token")
}
}kotlin
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual class SecureStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
actual suspend fun save(key: String, value: String) = withContext(Dispatchers.IO) {
encryptedPrefs.edit().putString(key, value).apply()
}
actual suspend fun get(key: String): String? = withContext(Dispatchers.IO) {
encryptedPrefs.getString(key, null)
}
actual suspend fun remove(key: String) = withContext(Dispatchers.IO) {
encryptedPrefs.edit().remove(key).apply()
}
actual suspend fun clear() = withContext(Dispatchers.IO) {
encryptedPrefs.edit().clear().apply()
}
}kotlin
import kotlinx.cinterop.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.*
import platform.Security.*
actual class SecureStorage {
private val serviceName = "com.example.app"
actual suspend fun save(key: String, value: String) = withContext(Dispatchers.Default) {
val data = value.encodeToByteArray().toNSData()
// 先删除旧值
val deleteQuery = mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key
)
SecItemDelete(deleteQuery as CFDictionaryRef)
// 添加新值
val addQuery = mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key,
kSecValueData to data,
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlock
)
val status = SecItemAdd(addQuery as CFDictionaryRef, null)
if (status != errSecSuccess) {
throw Exception("Keychain save failed with status: $status")
}
}
actual suspend fun get(key: String): String? = withContext(Dispatchers.Default) {
memScoped {
val query = mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key,
kSecReturnData to kCFBooleanTrue,
kSecMatchLimit to kSecMatchLimitOne
)
val result = alloc<CFTypeRefVar>()
val status = SecItemCopyMatching(query as CFDictionaryRef, result.ptr)
if (status == errSecSuccess) {
val data = result.value as NSData
return@withContext NSString.create(data, NSUTF8StringEncoding) as String
} else if (status == errSecItemNotFound) {
return@withContext null
} else {
throw Exception("Keychain read failed with status: $status")
}
}
}
actual suspend fun remove(key: String) = withContext(Dispatchers.Default) {
val query = mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName,
kSecAttrAccount to key
)
SecItemDelete(query as CFDictionaryRef)
}
actual suspend fun clear() = withContext(Dispatchers.Default) {
val query = mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to serviceName
)
SecItemDelete(query as CFDictionaryRef)
}
}
// 扩展函数:ByteArray 转 NSData
private fun ByteArray.toNSData(): NSData {
return NSData.create(bytes = this.refTo(0), length = this.size.toULong())
}kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.security.SecureRandom
import java.util.Base64
import java.util.prefs.Preferences
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
actual class SecureStorage(private val nodeName: String = "secure_storage") {
private val prefs = Preferences.userRoot().node(nodeName)
private val keyAlias = "master_key"
private val secretKey: SecretKey = getOrCreateKey()
private fun getOrCreateKey(): SecretKey {
val encodedKey = prefs.get(keyAlias, null)
return if (encodedKey != null) {
val decodedKey = Base64.getDecoder().decode(encodedKey)
SecretKeySpec(decodedKey, "AES")
} else {
val keyGen = KeyGenerator.getInstance("AES")
keyGen.init(256, SecureRandom())
val key = keyGen.generateKey()
prefs.put(keyAlias, Base64.getEncoder().encodeToString(key.encoded))
prefs.flush()
key
}
}
actual suspend fun save(key: String, value: String) = withContext(Dispatchers.IO) {
val encrypted = encrypt(value)
prefs.put(key, encrypted)
prefs.flush()
}
actual suspend fun get(key: String): String? = withContext(Dispatchers.IO) {
val encrypted = prefs.get(key, null) ?: return@withContext null
decrypt(encrypted)
}
actual suspend fun remove(key: String) = withContext(Dispatchers.IO) {
prefs.remove(key)
prefs.flush()
}
actual suspend fun clear() = withContext(Dispatchers.IO) {
prefs.clear()
prefs.flush()
}
private fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val iv = ByteArray(12).apply { SecureRandom().nextBytes(this) }
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec)
val encrypted = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = iv + encrypted
return Base64.getEncoder().encodeToString(combined)
}
private fun decrypt(ciphertext: String): String {
val combined = Base64.getDecoder().decode(ciphertext)
val iv = combined.copyOfRange(0, 12)
val encrypted = combined.copyOfRange(12, combined.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
val decrypted = cipher.doFinal(encrypted)
return String(decrypted, Charsets.UTF_8)
}
}代码封装示例
以下是带类型安全和错误处理的完整封装:
kotlin
// commonMain
sealed class SecureStorageResult<out T> {
data class Success<T>(val data: T) : SecureStorageResult<T>()
data class Error(val exception: Throwable) : SecureStorageResult<Nothing>()
}
interface SecureAuthStorage {
suspend fun saveToken(token: String): SecureStorageResult<Unit>
suspend fun getToken(): SecureStorageResult<String?>
suspend fun saveRefreshToken(token: String): SecureStorageResult<Unit>
suspend fun getRefreshToken(): SecureStorageResult<String?>
suspend fun clearAllTokens(): SecureStorageResult<Unit>
}
class SecureAuthStorageImpl(
private val storage: SecureStorage
) : SecureAuthStorage {
private companion object {
const val KEY_AUTH_TOKEN = "auth_token"
const val KEY_REFRESH_TOKEN = "refresh_token"
}
override suspend fun saveToken(token: String): SecureStorageResult<Unit> = runCatching {
storage.save(KEY_AUTH_TOKEN, token)
}.fold(
onSuccess = { SecureStorageResult.Success(Unit) },
onFailure = { SecureStorageResult.Error(it) }
)
override suspend fun getToken(): SecureStorageResult<String?> = runCatching {
storage.get(KEY_AUTH_TOKEN)
}.fold(
onSuccess = { SecureStorageResult.Success(it) },
onFailure = { SecureStorageResult.Error(it) }
)
override suspend fun saveRefreshToken(token: String): SecureStorageResult<Unit> = runCatching {
storage.save(KEY_REFRESH_TOKEN, token)
}.fold(
onSuccess = { SecureStorageResult.Success(Unit) },
onFailure = { SecureStorageResult.Error(it) }
)
override suspend fun getRefreshToken(): SecureStorageResult<String?> = runCatching {
storage.get(KEY_REFRESH_TOKEN)
}.fold(
onSuccess = { SecureStorageResult.Success(it) },
onFailure = { SecureStorageResult.Error(it) }
)
override suspend fun clearAllTokens(): SecureStorageResult<Unit> = runCatching {
storage.remove(KEY_AUTH_TOKEN)
storage.remove(KEY_REFRESH_TOKEN)
}.fold(
onSuccess = { SecureStorageResult.Success(Unit) },
onFailure = { SecureStorageResult.Error(it) }
)
}第三方库推荐
自实现 vs 三方库
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 自实现 | 完全可控、无依赖 | 需处理平台差异 | 简单需求、学习目的 |
| KMP-NativeCoroutines + KeychainAccess | iOS 侧更优雅 | 需要桥接层 | iOS 为主的项目 |
| 自行封装 C Interop | 直接调用系统 API | 复杂度高 | 桌面端深度集成 |
推荐方案
对于大多数项目,建议自实现 Android 和 iOS 的安全存储,桌面端使用简化方案或集成系统 Credential Manager。
依赖补充
Android EncryptedSharedPreferences
kotlin
kotlin {
sourceSets {
androidMain.dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
}
}groovy
kotlin {
sourceSets {
androidMain {
dependencies {
implementation "androidx.security:security-crypto:1.1.0-alpha06"
}
}
}
}toml
[versions]
androidx-security = "1.1.0-alpha06"
[libraries]
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }最新版本查看链接:https://developer.android.com/jetpack/androidx/releases/security
iOS Keychain(无需额外依赖)
iOS Keychain 是系统 API,无需添加依赖,仅需通过 cinterop 调用。
实战坑点
Android 密钥初始化失败
KeyStore 异常
在某些设备上(尤其是低端 Android 设备),Keystore 可能未正确初始化,导致 KeyPermanentlyInvalidatedException。
解决方案:
kotlin
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
} catch (e: Exception) {
// 降级为普通加密存储或提示用户
Log.e("SecureStorage", "Keystore init failed", e)
}iOS Keychain 错误码处理
错误码易忽略
SecItemAdd 返回 -25299 (errSecDuplicateItem) 表示键已存在,必须先删除再添加。
解决方案:
在 save() 方法中先调用 SecItemDelete,忽略返回值。
Desktop 密钥泄露风险
JVM 主密钥明文存储
上述 Desktop 示例将加密密钥存储于 Preferences,攻击者可直接读取。
解决方案:
- 使用用户密码派生密钥(PBKDF2)
- 集成 Windows DPAPI 或 Linux Secret Service
- 提示用户:桌面端安全性低于移动端
生物识别绑定
Android 生物识别绑定
可以将密钥绑定生物识别,用户必须验证指纹才能解密。
示例(Android):
kotlin
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
"biometric_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(30, KeyProperties.AUTH_BIOMETRIC_STRONG)
.build()数据迁移
::: caution 应用更新时的数据迁移 更换加密方案时,需要妥善处理旧数据的解密和重新加密。 :::
迁移策略:
kotlin
suspend fun migrateToSecureStorage(oldSettings: Settings, secureStorage: SecureStorage) {
val token = oldSettings.getString("auth_token", "")
if (token.isNotEmpty()) {
secureStorage.save("auth_token", token)
oldSettings.remove("auth_token")
}
}