移动端登录态安全设计(3):AES-GCM + Android Keystore:Android Token 本地安全存储
前两篇我们已经讲清楚了两个问题第一篇移动端登录态安全设计1App 登录时密码到底要不要加密为什么通常走 HTTPS_前端登录输入密码 传输到后端,建议加密吗-CSDN博客讲的是App 登录时密码到底要不要加密为什么通常走 HTTPS第二篇移动端登录态安全设计2Token 拿到后为什么不能明文存_登录token更登录令牌-CSDN博客讲的是Token 拿到后为什么不能明文存这一篇开始进入真正的落地方案。如果 App 登录成功后后端返回{ accessToken: xxx, refreshToken: yyy }那么 Android 端比较合理的本地安全存储方案是Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ 存到 DataStore / MMKV / SharedPreferences AES key ↓ Android Keystore 生成和保护一句话总结Token 存密文AES key 进 Keystore。这篇文章就专门讲清楚1. AES-GCM 负责什么 2. Android Keystore 负责什么 3. Token 为什么不是直接存进 Keystore 4. Android 端代码结构应该怎么拆 5. 拦截器里怎么取 Token 6. 老版本明文 Token 怎么迁移一、先把整体关系画出来Token 本地安全存储不是单独靠某一个组件完成的。它是三层配合Android Keystore ↓ 保护 AES key AES-GCM ↓ 用 AES key 加密 / 解密 Token DataStore / MMKV / SharedPreferences ↓ 保存加密后的 Token 密文所以不要理解成用了 DataStoreToken 就安全了。 用了 MMKVToken 就安全了。 用了 KeystoreToken 就直接放进去了。正确理解是DataStore / MMKV只是存储容器 AES-GCM真正加密 Token Android Keystore保护 AES key也就是说本地文件里保存的是密文不是原始 Token。二、为什么 Token 不直接存进 Keystore很多人第一次听到 Android Keystore会以为那我直接把 Token 存进 Keystore 不就行了这个理解不准确。Android Keystore 的核心作用不是存业务数据而是存和保护加密密钥。更准确地说Keystore 存 key AES 用 key 加密 Token Token 密文存在本地存储也就是Android Keystore ↓ AES key AES key ↓ 加密 Token Token 密文 ↓ DataStore / MMKV / SharedPreferences所以这句话非常关键Keystore 不是用来存 Token 的Keystore 是用来保护加密 Token 的密钥。三、为什么选择 AES-GCMToken 是一段后续还要拿出来使用的凭证。比如Authorization: Bearer accessToken所以 Token 不能用 MD5 / SHA 处理。因为 MD5 / SHA 是摘要不能还原。Token 本地存储需要的是可以加密 也可以解密所以要使用对称加密算法。AES 就是常见的对称加密算法。而在 Android 本地加密 Token 这种场景下推荐使用AES/GCM/NoPadding为什么是 GCM因为 GCM 不只是加密还能校验密文有没有被篡改。可以理解为AES-GCM 加密 完整性校验如果本地密文被人改了解密时会失败。四、不要使用 AES/ECB很多老代码里可能会看到AES/ECB/PKCS5Padding这个不建议使用。ECB 模式的问题是相同明文块会加密出相同密文块容易暴露数据模式。Token 这种敏感数据存储不要使用 ECB。建议统一使用AES/GCM/NoPadding同时每次加密都要使用新的 IV。常见做法AES key由 Android Keystore 生成 IV每次加密随机生成一般 12 字节 cipherTextAES-GCM 加密后的密文最终本地保存{ iv: base64 iv, cipherText: base64 cipherText }IV 不需要保密但同一个 key 下 IV 不能重复。五、AES key 不能写死错误做法private const val AES_KEY 1234567890123456这类写法非常危险。因为 APK 可以被反编译。如果 AES key 写死在代码里攻击者拿到 APK 后就有机会分析出 key。一旦 key 被拿到本地密文 Token 也就可能被解开。所以 AES key 应该由 Android Keystore 生成和保护。正确方向是App 第一次需要加密 Token ↓ 检查 Keystore 里有没有 AES key ↓ 没有就生成一个 ↓ 后续都通过这个 key 加解密 Token六、Android Keystore 和 APK 签名 keystore 不是一个东西这里必须单独说一次。Android 开发里有两个容易混的 Keystore。第一个是打包签名用的debug.keystore release.jks xxx.keystore它用于APK / AAB 签名 应用升级校验 第三方平台 SHA1 / SHA256 配置第二个是本文说的Android Keystore System它用于App 运行时生成和保护加密密钥所以APK 签名 keystore发布体系 Android Keystore System运行时密钥保护体系这两个不是一个东西。Token 本地加密用的是第二个。七、推荐的代码结构不要把加密逻辑、存储逻辑、拦截器逻辑写在一起。推荐拆成四层TokenManager ↓ SecureTokenStore ↓ CryptoManager ↓ KeystoreKeyManager每一层职责不同TokenManager 管理登录态比如保存 Token、读取 accessToken、清空 Token。 SecureTokenStore 负责 Token 的安全存储对外不暴露明文落地细节。 CryptoManager 负责 AES-GCM 加密和解密。 KeystoreKeyManager 负责从 Android Keystore 获取或创建 AES key。OkHttp 拦截器只依赖 TokenManager。它不应该直接关心 DataStore、MMKV、AES、Keystore。八、定义 Token 数据结构业务上可以先定义一个 TokenEntitydata class TokenEntity( val accessToken: String, val refreshToken: String, val expiresAt: Long )加密后保存的数据可以这样定义data class EncryptedValue( val iv: String, val cipherText: String )如果 accessToken 和 refreshToken 分开加密可以分别保存data class EncryptedTokenEntity( val accessTokenIv: String, val accessTokenCipherText: String, val refreshTokenIv: String, val refreshTokenCipherText: String, val expiresAt: Long )注意expiresAt 是否加密看安全要求。如果只是判断过期时间单独保存问题不大。但如果你希望整个 TokenEntity 都作为敏感数据也可以把整个 JSON 序列化后一起加密。九、KeystoreKeyManager生成和获取 AES key核心代码大概如下class KeystoreKeyManager { companion object { private const val ANDROID_KEYSTORE AndroidKeyStore private const val KEY_ALIAS token_aes_key } fun getOrCreateSecretKey(): SecretKey { val keyStore KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val existingKey keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry if (existingKey ! null) { return existingKey.secretKey } val keyGenerator KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE ) val keySpec KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setKeySize(256) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .build() keyGenerator.init(keySpec) return keyGenerator.generateKey() } }这段代码做了几件事1. 打开 Android Keystore 2. 判断是否已经存在 token_aes_key 3. 如果存在直接返回 4. 如果不存在生成一个 AES key 5. 限制它只能用于 GCM 模式和无填充这里的 key 不会像普通字符串那样保存在代码里。它由系统 Keystore 管理。十、CryptoManagerAES-GCM 加密和解密加密逻辑class CryptoManager( private val keyManager: KeystoreKeyManager ) { companion object { private const val TRANSFORMATION AES/GCM/NoPadding private const val IV_SIZE 12 } fun encrypt(plainText: String): EncryptedValue { val secretKey keyManager.getOrCreateSecretKey() val iv ByteArray(IV_SIZE) SecureRandom().nextBytes(iv) val cipher Cipher.getInstance(TRANSFORMATION) val spec GCMParameterSpec(128, iv) cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec) val cipherText cipher.doFinal( plainText.toByteArray(Charsets.UTF_8) ) return EncryptedValue( iv Base64.encodeToString(iv, Base64.NO_WRAP), cipherText Base64.encodeToString(cipherText, Base64.NO_WRAP) ) } fun decrypt(encryptedValue: EncryptedValue): String { val secretKey keyManager.getOrCreateSecretKey() val iv Base64.decode(encryptedValue.iv, Base64.NO_WRAP) val cipherText Base64.decode(encryptedValue.cipherText, Base64.NO_WRAP) val cipher Cipher.getInstance(TRANSFORMATION) val spec GCMParameterSpec(128, iv) cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) val plainText cipher.doFinal(cipherText) return plainText.toString(Charsets.UTF_8) } }这里有几个关键点1. 每次 encrypt 都生成新的 IV 2. IV 和 cipherText 都用 Base64 转成字符串存储 3. decrypt 时需要同一个 IV 和 cipherText 4. 如果密文被篡改decrypt 会失败Base64 不是加密。Base64 只是为了把二进制数据变成字符串方便存到 DataStore / MMKV / SharedPreferences 里。十一、SecureTokenStore加密保存 Token这里先用接口表达不绑定具体存储框架。interface SecureTokenStore { suspend fun saveToken(token: TokenEntity) suspend fun getToken(): TokenEntity? suspend fun clearToken() }然后具体实现里负责保存时 TokenEntity → JSON → AES-GCM 加密 → 保存密文 读取时 读取密文 → AES-GCM 解密 → JSON → TokenEntity伪代码class SecureTokenStoreImpl( private val cryptoManager: CryptoManager, private val localStore: LocalKeyValueStore, private val json: Json ) : SecureTokenStore { companion object { private const val KEY_TOKEN_IV token_iv private const val KEY_TOKEN_CIPHER_TEXT token_cipher_text } override suspend fun saveToken(token: TokenEntity) { val tokenJson json.encodeToString(token) val encryptedValue cryptoManager.encrypt(tokenJson) localStore.putString(KEY_TOKEN_IV, encryptedValue.iv) localStore.putString(KEY_TOKEN_CIPHER_TEXT, encryptedValue.cipherText) } override suspend fun getToken(): TokenEntity? { val iv localStore.getString(KEY_TOKEN_IV) val cipherText localStore.getString(KEY_TOKEN_CIPHER_TEXT) if (iv.isNullOrBlank() || cipherText.isNullOrBlank()) { return null } return runCatching { val tokenJson cryptoManager.decrypt( EncryptedValue( iv iv, cipherText cipherText ) ) json.decodeFromStringTokenEntity(tokenJson) }.getOrNull() } override suspend fun clearToken() { localStore.remove(KEY_TOKEN_IV) localStore.remove(KEY_TOKEN_CIPHER_TEXT) } }这里的 localStore 可以是DataStore MMKV SharedPreferences下一篇可以专门讲 DataStore、MMKV、SharedPreferences 怎么选。这一篇先记住localStore 只存密文不存 Token 明文。十二、LocalKeyValueStore屏蔽底层存储为了后续方便切换 DataStore / MMKV可以抽一个接口interface LocalKeyValueStore { suspend fun putString(key: String, value: String) suspend fun getString(key: String): String? suspend fun remove(key: String) suspend fun clear() }这样 Token 存储上层不关心底层到底是 DataStore 还是 MMKV。后续可以有不同实现DataStoreLocalKeyValueStore MmkvLocalKeyValueStore SharedPreferencesLocalKeyValueStore这就是工程设计里的隔离。不要让 TokenManager 直接依赖 MMKV 或 DataStore。十三、TokenManager统一管理登录态TokenManager 负责业务层面的 Token 管理。class TokenManager( private val secureTokenStore: SecureTokenStore ) { Volatile private var memoryToken: TokenEntity? null suspend fun saveToken(token: TokenEntity) { memoryToken token secureTokenStore.saveToken(token) } suspend fun restoreToken(): TokenEntity? { val token secureTokenStore.getToken() memoryToken token return token } fun getAccessTokenFromMemory(): String? { return memoryToken?.accessToken } suspend fun getAccessToken(): String? { memoryToken?.let { return it.accessToken } return restoreToken()?.accessToken } suspend fun clearToken() { memoryToken null secureTokenStore.clearToken() } }为什么要有内存缓存因为 OkHttp Interceptor 是同步接口而 DataStore 是协程 / Flow 风格。为了避免每次请求都阻塞读取本地存储可以在登录成功或 App 启动时把 Token 从本地恢复到内存。请求时优先从内存拿 accessToken。十四、OkHttp 拦截器里怎么使用拦截器不应该直接解密 Token。它只负责向 TokenManager 要 accessToken 然后加到请求头示例class AuthInterceptor( private val tokenManager: TokenManager ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val accessToken tokenManager.getAccessTokenFromMemory() val newRequest if (accessToken.isNullOrBlank()) { request } else { request.newBuilder() .header(Authorization, Bearer $accessToken) .build() } return chain.proceed(newRequest) } }这里要注意Header 里放的是原始 accessToken不是密文。因为后端只认识原始 Token。安全点在于本地落地保存的是密文。 运行时短暂使用明文。 传输过程走 HTTPS。 日志里不打印 Token。十五、App 启动时恢复 TokenApp 启动时可以做一次恢复class AppInitializer( private val tokenManager: TokenManager ) { suspend fun init() { tokenManager.restoreToken() } }实际项目里可以在启动页、Application 初始化流程、或者登录态初始化模块里处理。流程是App 启动 ↓ 从本地读取 Token 密文 ↓ 用 Keystore 里的 AES key 解密 ↓ 恢复 Token 到内存 ↓ 后续请求拦截器从内存拿 accessToken这样拦截器逻辑就比较干净。十六、退出登录时清理 Token退出登录时不要只跳转登录页。应该完整清理1. 调后端 logout 接口 2. 后端让 refreshToken 失效 3. 客户端清空内存 Token 4. 客户端清空本地密文 Token 5. 清空用户信息缓存 6. 跳转登录页客户端代码示例class LogoutUseCase( private val authApi: AuthApi, private val tokenManager: TokenManager, private val userStore: UserStore ) { suspend fun logout() { runCatching { authApi.logout() } tokenManager.clearToken() userStore.clear() } }注意即使 logout 接口失败本地 Token 也要清掉。因为用户点击退出后客户端登录态必须退出。十七、老版本明文 Token 迁移如果项目之前是明文保存 Token现在改成 AES-GCM Keystore必须考虑老版本迁移。流程1. 先尝试读取新版本加密 Token 2. 如果没有再读取旧版本明文 Token 3. 如果读到了旧 Token就加密保存到新位置 4. 删除旧 Token 明文示例class TokenMigration( private val oldPlainTokenStore: OldPlainTokenStore, private val secureTokenStore: SecureTokenStore ) { suspend fun migrateIfNeeded() { val newToken secureTokenStore.getToken() if (newToken ! null) { return } val oldToken oldPlainTokenStore.getToken() if (oldToken ! null) { secureTokenStore.saveToken(oldToken) oldPlainTokenStore.clear() } } }这个迁移逻辑很重要。否则用户升级 App 后可能登录态直接丢失。十八、异常情况怎么处理Token 解密可能失败。比如1. 密文被篡改 2. Keystore key 丢失 3. 用户恢复了旧备份 4. App 数据异常 5. 系统安全模块异常解密失败时不要崩溃。可以统一处理成1. 清空本地 Token 2. 清空内存 Token 3. 回到登录页 4. 必要时上报非敏感错误日志示例override suspend fun getToken(): TokenEntity? { val iv localStore.getString(KEY_TOKEN_IV) val cipherText localStore.getString(KEY_TOKEN_CIPHER_TEXT) if (iv.isNullOrBlank() || cipherText.isNullOrBlank()) { return null } return runCatching { val tokenJson cryptoManager.decrypt( EncryptedValue(iv, cipherText) ) json.decodeFromStringTokenEntity(tokenJson) }.getOrElse { clearToken() null } }这里不要把密文、Token、异常上下文里的敏感内容打进日志。十九、日志脱敏必须一起做Token 加密存储做完后还要处理日志。否则会出现本地存储是密文 但 OkHttp 日志里打印了 Authorization这就等于绕过了本地加密。至少要处理Authorization accessToken refreshToken Cookie password 验证码OkHttp 日志拦截器里应该脱敏 Headerval loggingInterceptor HttpLoggingInterceptor().apply { level HttpLoggingInterceptor.Level.BODY redactHeader(Authorization) redactHeader(Cookie) }自己的日志系统也要加统一脱敏object SensitiveMasker { fun mask(input: String): String { return input .replace(Regex(Bearer\\s[A-Za-z0-9._\\-]), Bearer ***) .replace(Regex(\accessToken\\\s*:\\s*\[^\]\), \accessToken\:\***\) .replace(Regex(\refreshToken\\\s*:\\s*\[^\]\), \refreshToken\:\***\) .replace(Regex(\password\\\s*:\\s*\[^\]\), \password\:\***\) } }安全设计不是只防黑客也要防自己把敏感数据打印出去。二十、这套方案能不能绝对安全不能。移动端没有绝对安全。即使用了AES-GCM Android Keystore DataStore 日志脱敏 HTTPS在 Root、Hook、Frida、Xposed、动态调试环境下攻击者仍然可能在运行时拿到 Token。因为 App 最终要把 accessToken 解密出来放到 Authorization Header 里。所以客户端安全的目标不是永远不可能泄漏。而是尽量不泄漏。 泄漏后尽快失效。 服务端能识别异常。因此还需要后端配合accessToken 短有效期 refreshToken 存 Redis refreshToken 轮换 登出删除 refreshToken 改密码后全端失效 后台踢设备 deviceId 绑定 jti 标识 Token tokenVersion 控制旧 Token 失效客户端负责防泄漏后端负责可失效。这才是完整闭环。二十一、最终落地清单这篇文章的落地清单如下1. 不再明文保存 accessToken / refreshToken。 2. 使用 AES-GCM 加密 Token。 3. AES key 由 Android Keystore 生成和保护。 4. DataStore / MMKV / SharedPreferences 只保存密文和 IV。 5. TokenManager 统一收口 Token 存取。 6. OkHttp 拦截器只从 TokenManager 获取 accessToken。 7. accessToken 可以内存缓存refreshToken 必须加密持久化。 8. App 启动时从本地密文恢复 Token 到内存。 9. 退出登录时清空内存 Token 和本地密文 Token。 10. 老版本明文 Token 要迁移到新加密存储。 11. 解密失败时清空 Token 并回登录态失效流程。 12. OkHttp、本地日志、Crash 上报全部做 Token 脱敏。 13. 后端配合 Token 失效、refreshToken 轮换、踢下线。二十二、总结Android Token 本地安全存储的核心不是到底用 DataStore 还是 MMKV而是Token 不能明文落地。真正的安全结构是Android Keystore ↓ 保护 AES key AES-GCM ↓ 加密 Token DataStore / MMKV ↓ 保存 Token 密文所以本文最重要的一句话是Token 存密文AES key 进 Keystore。再完整一点本地保存的是密文运行时短暂解密网络传输走 HTTPS日志系统做脱敏服务端负责 Token 失效和风险兜底。这才是 Android 端比较完整的 Token 本地安全存储方案。