Skip to content

安全存储

源:Android Keystore | iOS Keychain

在处理敏感数据(如用户凭证、API Token、加密密钥)时,普通的 KV 存储是不够的。本文将展示如何利用各平台的安全存储机制保护敏感信息。

平台差异对比

平台原生 API底层实现关键特性安全级别
AndroidEncryptedSharedPreferences + KeystoreAES-256 加密,密钥存储于 TEE/SE硬件支持、生物识别绑定⭐⭐⭐⭐⭐
iOS/macOSKeychain Services系统级加密,存储于 Secure Enclave自动 iCloud 同步、Touch ID/Face ID⭐⭐⭐⭐⭐
Desktop (JVM)Preferences + 自定义加密需手动实现加密逻辑无硬件支持⭐⭐⭐
Desktop (Native)OS Credential ManagerWindows Credential Vault / macOS Keychain系统级集成⭐⭐⭐⭐

expect/actual 实现方案

API 核心签名说明

  • expect class SecureStorage
  • suspend 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 + KeychainAccessiOS 侧更优雅需要桥接层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,攻击者可直接读取。

解决方案:

  1. 使用用户密码派生密钥(PBKDF2)
  2. 集成 Windows DPAPI 或 Linux Secret Service
  3. 提示用户:桌面端安全性低于移动端

生物识别绑定

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")
    }
}