Kotlin:令牌、密钥和 PII 的安全存储检查表
Kotlin 安全存储 检查表:在 Android Keystore、EncryptedSharedPreferences 和数据库加密之间为令牌、密钥和个人信息选择合适方案。

你要保护的东西(用通俗的话说)
在业务应用里,安全存储的核心目标很简单:即便有人拿到手机(或你的应用文件),也不能读取或重用你保存的内容。这既包括磁盘上的静态数据,也包括通过备份、日志、崩溃报告或调试工具泄露的秘密。
一个简单的思考题:如果陌生人打开了你应用的存储目录,他能做什么?在很多应用里,最有价值的并不是照片或设置,而是那些能解锁访问的小字符串。
设备上的存储通常包含会话令牌(用于保持登录状态)、刷新令牌、API 密钥、加密密钥、个人数据(PII)如姓名和邮箱,以及离线使用的缓存业务记录(订单、工单、客户备注)。
常见的真实失败模式包括:
- 设备丢失或被盗后,令牌被复制用来冒充用户。
- 恶意软件或“辅助”应用在已 root 的设备上或通过无障碍手段读取本地文件。
- 自动设备备份把你的应用数据移动到了你没计划的位置。
- 调试构建把令牌写入日志、崩溃报告,或禁用了安全检查。
所以,“就放到 SharedPreferences 里”并不适合任何能授予访问权限(令牌)或会伤害用户与公司(PII)的内容。普通的 SharedPreferences 就像把秘密写在贴纸上,放在应用里:方便,但一旦有人有机会读取,就很容易泄露。
最有用的起点是为每项存储命名并问两个问题:它会解锁什么?如果它公开会带来什么后果?其余的(Keystore、加密偏好、加密数据库)由此推导。
给数据分类:令牌、密钥和 PII
当你不再把所有“敏感数据”当成一类问题时,安全存储会变得更容易。先列出应用保存的内容以及泄露后会发生什么。
**令牌(Tokens)**不是密码。访问令牌和刷新令牌通常需要存储以保持用户登录,但它们仍然是高价值的秘密。密码不应该被存储。如果需要登录,尽量只存保持会话所需的最小内容(通常是令牌),密码验证交给服务器完成。
**密钥(Keys)**是另一类。API 密钥、签名密钥和加密密钥可能能解锁整个系统,而不仅仅是一个用户账号。如果有人从设备中提取出它们,就能大规模自动滥用。一条好规则是:如果一个值可以在应用外被用来冒充应用或解密数据,就把它视为比单个用户令牌更高风险。
**PII(个人可识别信息)**是任何能识别个人的数据:邮箱、电话、家庭住址、客户备注、身份证、与健康相关的数据。看似无害的字段在合并后也可能变得敏感。
一个实践中有效的快速标记法:
- 会话类秘密:access token、refresh token、会话 cookie
- 应用级秘密:API 密钥、签名密钥、加密密钥(尽量不要放在设备上)
- 用户数据(PII):个人资料、标识符、证件、医疗或财务信息
- 设备与分析 ID:广告 ID、设备 ID、安装 ID(在许多策略下仍然敏感)
Android Keystore:什么时候使用
当你需要保护不应以明文形式离开设备的秘密时,Android Keystore 是最佳选择。它是加密密钥的保险箱,而不是存放实际数据的数据库。
它擅长于生成并保存用于加密、解密、签名或校验的密钥。通常你会在别处加密一个令牌或离线数据,而 Keystore 中的密钥则用于解锁它们。
硬件支持密钥:实际意味着什么
在许多设备上,Keystore 的密钥可以是硬件支持的。也就是说,密钥操作在受保护环境中完成,密钥材料无法被提取。这降低了能读取应用文件的恶意软件带来的风险。
硬件支持并不是所有设备的保证,不同机型和 Android 版本表现也不同。要以密钥操作可能失败的方式构建应用。
用户认证门控
Keystore 可以在使用密钥前要求用户存在验证。这就是把访问与生物识别或设备凭证绑定的方法。例如,你可以加密一个导出令牌,只有用户指纹或 PIN 确认后才能解密。
当你想要不可导出的密钥、希望对敏感操作使用生物识别或设备凭证,或希望密钥只在单个设备上有效且不随备份同步时,Keystore 是非常合适的选择。
要为常见坑做准备:锁屏设置更改、生物识别更改或安全事件后密钥可能被作废。要预期失败并实现干净的回退:检测无效密钥,清除加密数据,并提示用户重新登录。
EncryptedSharedPreferences:什么时候足够
EncryptedSharedPreferences 是一组小型键值秘密的良好默认选项。它是“加密后的 SharedPreferences”,因此别人不能直接打开文件读取值。
底层它使用一个主密钥来加密和解密值。该主密钥受 Android Keystore 保护,所以应用不会以明文形式存储原始加密密钥。
对于少量经常读取的项(比如 access/refresh token、会话 ID、设备 ID、环境标志,或最后同步时间之类的小状态)通常足够。如果你确实必须存储极小的一些用户数据,也可以用它,但不要把它当成存放 PII 的垃圾桶。
它不适合任何大型或结构化的数据。如果你需要离线列表、搜索或按字段查询(客户、工单、订单),EncryptedSharedPreferences 会变得慢且笨拙。到那时你应该使用加密数据库。
一个简单规则:如果你能把每个存储的键列在一屏上,EncryptedSharedPreferences 可能就足够了。如果需要行和查询,就换方案。
数据库加密:什么时候需要
当你存储的不再是少量设置或单个令牌时,数据库加密就变得重要。如果你的应用在设备上保留业务数据,除非你加以保护,否则应假定这些数据可以从丢失的手机中被提取出来。
当你需要离线访问记录、本地缓存以提升性能、历史/审计记录或较长的笔记和附件时,数据库是合适的。
两种常见的加密方法
整库加密(常见为 SQLCipher 风格)对整个文件在磁盘上进行加密。应用用一个密钥打开数据库。这个方式易于推理,因为你不需要记住哪些列被保护。
应用层字段加密只在写入前对特定字段加密,读取后再解密。如果大多数记录不敏感,或你想保留现有数据库格式而不改变文件结构,这种方式可行。
权衡:机密性 vs 搜索和排序
整库加密可以在磁盘上隐藏一切,但一旦数据库被解锁,应用可以正常查询。
字段加密保护指定列,但你会失去对加密值的方便搜索和排序。对加密的姓氏排序通常不可行,搜索要么是“读取后解密再搜索”(很慢),要么是“存储额外索引”(增加复杂性并可能泄露信息)。
密钥管理基础
数据库密钥绝不应该写死或随应用发布。一个常见模式是生成一个随机的数据库密钥,然后用 Keystore 中的密钥把它封装(加密)后存储。登出时你可以删除封装后的密钥,把本地数据库视为一次性可丢弃,或者如果应用必须跨会话离线工作则保留它。
如何选择:务实比较
你不是在选择“总体上最安全”的选项,而是在选择最适合你应用使用方式的安全方案。
真正能驱动正确选择的问题包括:
- 数据读取频率是多少(每次启动或很少)?
- 数据量有多大(几字节还是上千条记录)?
- 如果泄露会怎样(只是烦恼、代价高昂、或需要法律上通报)?
- 是否需要离线访问、搜索或排序?
- 是否有合规要求(保留、审计、加密规则)?
一个可行的映射:
- 令牌(OAuth 的 access 和 refresh token)通常放在 EncryptedSharedPreferences,因为它们小且读得频繁。
- 密钥材料应尽可能保存在 Android Keystore 中,以减少被复制出设备的可能性。
- PII 和离线业务数据在存储超过几字段或需要离线列表与过滤时通常需要数据库加密。
在业务应用中混合数据是很常见的。一个实用模式是为本地数据库或文件生成随机的数据加密键(DEK),只把被封装的 DEK 存放在由 Keystore 支撑的密钥中,并在需要时对其进行轮换。
如果不确定,选择更简单且安全的路径:少存。除非确实需要,否则避免离线存放 PII,并把密钥保存在 Keystore 中。
分步指南:在 Kotlin 应用中实现安全存储
先把计划在设备上存储的每个值和它必须存在的确切原因写下来。这是阻止“以防万一”存储的最快方法。
在写代码前,决定规则:每项数据应保存多久、何时替换、以及“登出”到底意味着什么。比如 access token 可能 15 分钟过期,refresh token 可能更久,离线 PII 可能需要“30 天后删除”的硬性规则。
保持可维护的实现方式:
- 创建一个单一的 “SecureStorage” 包装层,让应用其余部分不直接接触 SharedPreferences、Keystore 或数据库。
- 把每项放在合适的位置:令牌放在 EncryptedSharedPreferences、加密密钥由 Android Keystore 保护、更大型的离线数据放在加密数据库中。
- 有意处理失败。如果安全存储失败,应安全地拒绝(fail closed)。不要悄悄退回到明文存储。
- 添加诊断但不泄露数据:记录事件类型和错误码,绝不要记录令牌、密钥或用户详情。
- 连接删除路径:登出、移除账号和“清除应用数据”应走同一清除流程。
然后测试那些在生产环境中会破坏安全存储的无聊情况:从备份还原、从旧版本升级、改变设备锁设置、迁移到新手机。确保用户不会陷入一个本地数据无法解密但应用不断重试的循环。
最后,把决策写成一页团队可遵循的文档:存什么、放哪、保留周期,以及解密失败时的处理方式。
常见错误会破坏安全存储
大多数失败并不是因为选错库,而是某个小捷径悄悄把秘密复制到了你不想存放的地方。
最大的红旗是刷新令牌(或长期会话令牌)以明文保存:SharedPreferences、普通文件、“临时”缓存或本地数据库列。若有人拿到备份、root 的设备镜像或调试构建产物,这些令牌可能活得比密码长。
秘密也会通过可见性泄露,而不是存储本身。把完整请求头写进日志、在调试时打印令牌、或在崩溃报告与分析事件中附带“有帮助的”上下文都可能把凭证暴露到设备之外。把日志当作公开信息。
密钥处理也是常见缺口。用一个密钥保护一切会扩大影响面。不轮换密钥意味着旧的泄露会长期有效。要包含密钥版本管理、轮换计划以及旧加密数据如何处理的策略。
别忘了“保险箱外”的路径
加密不能阻止云备份复制本地应用数据;不能阻止屏幕截图或录屏捕获 PII;不能阻止带有放宽设置的调试构建,或导出功能(CSV/分享表单)泄露敏感字段。剪贴板也可能泄露一次性验证码或账号号码。
另外,加密不能修复授权问题。如果用户登出后应用仍然显示 PII,或缓存数据在无重新认证的情况下可见,那就是访问控制上的漏洞。锁定 UI、在登出时清除敏感缓存,并在显示受保护数据前重新检查权限。
运营细节:生命周期、登出与边缘情况
安全存储不只是你放在哪里,还包括它随时间如何表现:应用休眠时、用户登出时、设备锁定时的行为。
对于令牌,要规划完整生命周期。Access token 应短期有效。Refresh token 应被当作密码对待。如果令牌过期,静默刷新;如果刷新失败(被撤销、密码变更、设备移除),停止重试并强制重新登录。还要支持服务器端的撤销。即使本地存储完全安全,如果不撤销被盗凭证也无济于事。
把生物识别用于需要重新认证的关键操作,而不是滥用在所有场景。当操作有真实风险时提示(查看 PII、导出数据、修改付款信息、显示一次性密钥);不要在每次打开应用都提示。
登出时要严格且可预测:
- 先清除内存中的副本(单例、拦截器或 ViewModel 缓存的令牌)。
- 擦除存储的令牌和会话状态(包括 refresh token)。
- 如果设计允许,移除或使本地加密密钥失效。
- 删除离线 PII 和缓存的 API 响应。
- 禁用可能重新抓取数据的后台任务。
业务应用中的边缘情况很重要:一台设备上多个账户、工作配置文件、备份/恢复、设备间传输与部分登出(切换公司/工作区而不是完全登出)。测试强制停止、操作系统升级和时钟变化,因为时间漂移会破坏过期逻辑。
篡改检测是一个权衡。基本检查(可调试构建、模拟器标志、简单的 root 信号、Play Integrity 判定)能减少随意滥用,但有决心的攻击者可以绕过它们。把篡改信号当作风险输入:限制离线访问、要求重新认证并记录事件。
发版前的快速检查清单
在发布前使用此清单,针对业务应用中安全存储实际失败的地方。
- 假设设备可能敌对。 如果攻击者有已 root 的设备或完整设备镜像,他们能否从应用文件、偏好、日志或截图中读取令牌、密钥或 PII?如果答案是“可能”,把秘密移到 Keystore 支撑的保护并保持数据负载加密。
- 检查备份与设备迁移。 把敏感文件排除在 Android Auto Backup、云备份和设备间传输之外。如果在恢复时丢失密钥会破坏解密,规划恢复流程(重新认证并重新拉取数据),而不是尝试去解密。
- 搜索磁盘上的明文泄露。 查看临时文件、HTTP 缓存、崩溃报告、分析事件和图片缓存,找出可能包含 PII 或令牌的地方。检查调试日志和 JSON 转储。
- 设置过期与轮换。 Access token 应短期有效,refresh token 受保护,并且服务器端会话可撤销。定义密钥轮换以及当令牌被拒绝时应用的行为(清除、重新认证、重试一次)。
- 重新安装与设备更换行为。 测试卸载并重新安装,然后离线打开。如果 Keystore 密钥丢失,应用应安全失败(清除加密数据、显示登录界面,避免部分读取导致状态损坏)。
一个快速验证是“糟糕的一天”测试:用户登出、更改密码、把备份恢复到新手机并在飞机上打开应用。结果应可预测:要么数据为正确用户解密,要么被清除并在登录后重新获取。
示例场景:一个在离线存储 PII 的业务应用
想象一个外勤销售应用,在信号差的地区使用。外勤人员早上登录,离线浏览分配的客户、添加会面笔记,随后再同步。这就是存储检查清单从理论变成防止真实泄露的场景。
一个实用的划分:
- Access token:短期并保存在 EncryptedSharedPreferences。
- Refresh token:更严格保护,并通过 Android Keystore 门控访问。
- 客户 PII(姓名、电话、地址):存放在加密的本地数据库中。
- 离线笔记和附件:存放在加密数据库中,并在导出与共享时格外小心。
现在再加两个功能,风险就改变了。
如果加入“记住我”,刷新令牌就成为重新进入账户的主钥匙。把它当作密码对待。根据用户群体,你可能要求在解密前进行设备解锁(PIN/图案/生物识别)。
如果加入离线模式,你保护的就不再只是会话,而是一整份有价值的客户名单。这通常会推动你采用数据库加密并制定清晰的登出规则:清除本地 PII、仅保留下一次登录所需的数据,并取消后台同步。
在真机上测试,而不仅仅是模拟器。至少验证锁屏/解锁行为、重装行为、备份/恢复以及工作资料或多用户隔离。
接下来的步骤:把它变成可重复的团队习惯
安全存储只有成为习惯才有效。写一份简短的存储策略,团队可以遵循:哪些放哪里(Keystore、EncryptedSharedPreferences、加密数据库)、哪些绝不存、登出时必须清除的内容。
把它纳入日常交付:完成定义、代码审查和发布检查的一部分。
一个轻量的审查者清单:
- 每个存储项都有标签(令牌、密钥材料或 PII)。
- 存储选择在代码注释中有理由说明。
- 登出与切换账号会移除正确的数据(并仅移除那些数据)。
- 错误和日志不会打印秘密或完整 PII。
- 有人负责该策略并保持其更新。
如果你的团队使用 AppMaster (appmaster.io) 来构建业务应用并导出 Android 客户端的 Kotlin 源代码,保持相同的 SecureStorage 包装层方法,以便生成代码和自定义代码遵循一致策略。
从小型 POC 开始
构建一个小型的概念验证(POC),存储一个身份令牌和一条 PII 记录(例如离线需要的客户电话号码)。然后测试新装、升级、登出、锁屏设置更改和清除应用数据。仅在擦除行为正确且可重复后再扩大范围。
常见问题
先列出你要存储的每一项以及存储的原因。将小型会话密钥(如 access 和 refresh token)放在 EncryptedSharedPreferences,把加密密钥保存在 Android Keystore 中,当你需要离线的业务记录或大量 PII 时使用加密数据库。
普通的 SharedPreferences 把值写在一个文件里,这个文件常常可能被设备备份、已 root 的设备文件访问或调试产物读取。如果值是令牌或任何个人信息,把它当作普通设置存储会让它更容易被复制并在应用外重用。
使用 Android Keystore 来生成并保存那些不应被导出的加密密钥。通常你用这些密钥去加密其他数据(令牌、数据库密钥、文件),并且可以要求在使用密钥前进行用户认证(生物识别或设备凭证)。
这意味着密钥操作可以在受保护的硬件环境中进行,密钥材料更难被提取,即便攻击者能读取应用文件也难以拿到密钥。不要假设所有设备都提供硬件支持或行为一致;要为失败情况设计恢复流程。
对于少量经常读取的键值类秘密(如 access/refresh token、会话 ID、小状态位)来说,它通常足够。它不适合大体量数据、结构化的离线记录,或需要查询过滤的内容(客户、工单、订单)。
当你在设备上存大量离线业务数据或 PII,需要查询/搜索/排序,或需要离线历史时,选择加密数据库。它能降低丢失设备暴露整份客户名单或笔记的风险,同时让应用在解密后正常运行。
整库加密保护整个文件在磁盘上的内容,更容易推理,因为不用逐列跟踪敏感字段。字段级加密适合少量列,但会让搜索和排序变得困难,且容易通过索引或派生字段意外泄露数据。
生成一个随机的数据库密钥,然后只以被封装(加密)后的形式存储,封装密钥使用 Keystore 支撑的密钥进行加密。绝不要把密钥写死在代码里或随应用发布,并决定在登出或密钥失效时的处理(通常:删除封装密钥并把本地数据视为可抛弃)。
锁屏或生物识别变更、操作系统安全事件、恢复或迁移场景都可能使密钥失效。要明确处理:检测解密失败,安全地清除加密数据或本地数据库,并提示用户重新登录,而不是循环重试或回退到明文存储。
大多数泄露发生在“保险库外”:日志、崩溃报告、分析事件、调试打印、HTTP 缓存、截图、剪贴板使用和备份/恢复路径。把日志当作公开信息,永远不要记录令牌或完整 PII,禁用不小心的导出路径,并确保登出时清除内存中的缓存和存储的数据。


