Android SharedPreferences加密实战:从明文存储到EncryptedSharedPreferences
如果你维护过一段时间的 Android 项目一定对SharedPreferences再熟悉不过。它简单、好用、轻量一个getSharedPreferences就能把用户偏好、登录状态、缓存配置存下来。但问题往往就藏在这种“无脑用”的习惯里——SharedPreferences默认使用明文 XML 文件存储数据这意味着一旦设备被 root、备份文件被提取或者调试器被接上你存在本地的 Token、手机号、账号密码就等于直接交给了攻击者。今天讲的这些内容基于我在 cheesesquare 项目里的实际改造经验把SharedPreferences的加密实践完整拆开给你看从原理到方案对比再附上一套可以直接抄作业的落地代码目标只有一个让你在 5 分钟内消除这个最常见的数据安全隐患。做 Android 开发的朋友应该都有过这种体验代码上线前做安全测试扫描工具报出一堆“本地存储明文敏感数据”的风险项点开一看全是SharedPreferences里的登录凭证。想改吧又怕牵一发动全身不改吧报告交不过去。这篇文章就是针对这个痛点写的。我会先讲清楚SharedPreferences为什么容易泄露数据然后对比几种主流的加密思路最后给出一个适合在类似 cheesesquare 这种模块化项目中快速落地的方案。不管你是刚接触 Android 的新手还是正在维护老项目的资深开发都能在文章里找到对应的操作路径。1. 为什么要给 SharedPreferences 加密数据泄露的真实场景1.1 明文存储的秘密SharedPreferences 的底层机制SharedPreferences看起来是一个封装好的 key-value 接口但底层实现并不神秘。每次调用putString、putInt、putBoolean之后数据会被组织成一个 XML 文件存放在应用私有目录的shared_prefs文件夹中对应的文件路径通常是这样/data/data/应用包名/shared_prefs/SharedPreferences文件名.xml这个路径是 Android 系统的标准行为所有应用一视同仁。打开这个 XML 文件你会看到类似下面这样的结构?xml version1.0 encodingutf-8 standaloneyes ? map string nameaccess_tokeneyJhbGciOiJIUzI1NiJ9.xxxxxxxx/string string nameuser_phone13800138000/string boolean nameis_vip valuetrue / /map不需要任何解码手段直接就能读。这就是我说的“明文存储”的含义。SharedPreferences本身没有提供任何加密机制也不会自动混淆或者压缩一切换到文件系统层面数据就是裸露的。很多人会有一个认识误区觉得应用私有目录是安全的因为普通应用访问不了其他应用的私有数据。这个理解在 Android 早期确实成立但放到今天的威胁模型下已经不够看了。攻击者获取数据的路径远比你想的多而/data/data/路径下的明文 XML 恰恰是其中最容易被利用的一环。1.2 攻击者怎么拿到你的数据几种常见泄露路径我来梳理几条常见的泄露路径你对照自己的实际场景看就知道这不是杞人忧天。root 过的设备这是最直接的路径。应用沙盒机制依赖 Linux 权限但 root 权限可以覆盖绝大多数限制。安装了 root 管理工具的设备拿到shared_prefs目录下的 XML 文件就是一条cat命令的事。备份文件提取Android 的adb backup在旧的安卓版本上可以直接导出应用数据生成的.ab文件经过简单处理后就能解开。即便新版系统收紧了备份策略一些厂商定制的 ROM 仍然留有流量口。设备丢失或送修手机丢了或者送去维修对方如果对数据有想法明文 XML 是最容易得手的部分。尤其很多用户习惯把登录 Token 和账号信息一起存拿到这些基本等于账号失守。日志与调试通道一些开发同学为了排查方便会把关键字段临时打印到 Logcat 里或者通过StrictMode等方式把数据输出到调试通道。SharedPreferences明文数据在配合这些调试行为时泄露概率大大增加。恶意应用与供应链风险如果设备装了什么不干净的输入法、桌面或者应用商店一旦它们获取到了不必要的权限或者利用系统漏洞越权读取其他应用的数据明文 XML 就是最先被翻的目标之一。看到这里你应该明白SharedPreferences的问题不在于它本身而在于它把所有数据都裸奔在文件系统里。就好比你把家门钥匙和备用钥匙都挂在了门口挂钩上锁再结实也没意义。要解决这个问题核心思路只有一个在数据落盘之前做加密确保即便文件被拿走对方看到的也只是密文。2. 主流 SP 加密方案横向对比从自研 AES 到系统安全组件2.1 自研 AES 加密简单但坑很多既然要加密最自然的想法是自己写一个工具类在putString的时候先加密再存进去在getString的时候先取出密文再解密。AES 对称加密算法是首选因为它的加解密速度够快密钥管理相对可控。于是很多项目里会出现类似下面的代码public class AesPrefs { private static final String KEY aHR0cHM6Ly9naXRodWIuY29tL0JvbBhQaWNr; // Base64编码的“密钥” public static String encrypt(String value) { try { Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(), AES); cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] bytes cipher.doFinal(value.getBytes()); return Base64.encodeToString(bytes, Base64.NO_WRAP); } catch (Exception e) { return value; } } }这个做法表面上实现了加密但问题非常明显。第一密钥硬编码在代码里。攻击者只要反编译 APK在classes.dex里搜一下字符串常量密钥就全部暴露了。这相当于把保险箱钥匙粘在了保险箱门上。第二加密模式使用有隐患。AES/CBC/PKCS5Padding 只是入门选择每次加密同一个值时输出固定密文容易受到重放攻击和密文替换攻击安全性并不达标。GCMGalois/Counter Mode这种带认证的加密模式才是更稳妥的选择但自己实现起来又要处理 IV、认证标签、密钥派生等细节稍有不慎就埋雷。第三异常处理过于随意。加密失败时直接返回明文这在调试阶段方便排查但到了生产环境就是灾难——你以为加密了实际数据还是明的。所以我的观点很明确除非你是密码学专家否则不要自研这套东西。Android 平台已经提供了成熟的安全存储方案何必自己造一个四处漏风的轮子2.2 第三方加密库怎么选功能与维护成本的双重考量如果不想自研那第三方库自然进入视野。市场上曾经流行过一些封装了 AES 加解密和SharedPreferences存储的库它们通常提供一套类似原生的 API开发上手快接口也顺手。第三方库的好处是省心类库作者已经帮你处理了 IV 生成、密钥派生、Base64 编码等琐碎问题。但选型时要问自己三个问题库是否还在维护选一个已经停止维护的库意味着安全问题没人修复SDK 版本更新后可能随时崩。密钥放在哪里很多第三方库为了兼容老设备密钥仍然存在应用私有目录里或硬编码在代码中安全性提升有限。API 是否稳定一些库频繁改动接口升级版本后需要大面积改代码维护成本反而更高。对比之后不难发现真正值得长期依赖的加密方案必须是密钥和算法都由系统安全能力兜底的那种。Android 生态里这个角色就是 Keystore。2.3 Android Keystore 与官方安全组件为什么推荐Android Keystore 是系统级的安全硬件抽象层它的核心价值在于密钥无法被导出。你可以通过它生成密钥用它加密数据但密钥本身始终留在 Keystore 的隔离环境里即使设备被 root攻击者也拿不走私钥。这一点是任何纯软件方案都做不到的。我推荐的落地路径是用 Android 官方提供的 Security 组件。它基于 Keystore 和 Tink 组合对外暴露了EncryptedSharedPreferences这个类直接可以以原生SharedPreferences的 API 形式使用。MasterKey masterKey new MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build(); SharedPreferences prefs EncryptedSharedPreferences.create( context, secure_prefs, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM );这段代码看起来简单背后做的事情却不少MasterKey会在 Android Keystore 中生成一个 AES-256 密钥写数据时key 会经过 AES-SIV 模式加密value 会经过 AES-GCM 模式加密读数据时自动解密。你不需要关心 IV、认证标签、数据格式这些细节全部由库内部处理。之所以推荐这个方案除了安全收益还有两个实际的考虑API 兼容性高EncryptedSharedPreferences实现了SharedPreferences接口老项目替换成本低。加密过程默认使用 AES-GCM 这类带认证的加密模式密文被篡改后会在解密时报异常避免攻击者悄悄修改本地数据。唯一需要注意的是任何加密方案都会引入额外的内存复制和多层序列化开销。好在SharedPreferences本身只适合存少量轻量级数据加密后的性能损耗对绝大多数应用来说可以忽略。3. 在 cheesesquare 项目中落地加密 SP5 分钟实操3.1 项目结构梳理确定需要加密的字段cheesesquare 是一个典型的采用 Material Design 设计风格的示例项目结构上通常包含数据层、业务层和 UI 层。如果你曾经基于这个项目做二次开发会发现它里面大量使用了SharedPreferences来做偏好存储日期、排序方式、用户操作记录等等。和很多真实项目一样它并没有从一开始就考虑数据加密。这次改造的切入点是先全局搜索项目中所有getSharedPreferences、getDefaultSharedPreferences的调用点按存储内容做分类判断哪些字段需要加密。存储内容是否敏感加密必要性登录 Token / Session ID高度敏感必须加密用户手机号 / 邮箱敏感必须加密用户昵称 / 头像 URL低敏感可视情况加密界面主题 / 语言偏好非敏感可以不加密我的经验是宁可多加密几个字段也不要在“这个字段不重要”的判断上太自信。数据一旦被拖走影响的往往不是单条数据而是整个账号体系的安全。3.2 封装一个可替换的 SecurePrefs 工具类确定了要加密的字段后接下来要做的不是在所有调用点把SharedPreferences替换成EncryptedSharedPreferences而是先封装一个上层工具类。这样做的好处是将来如果你想换加密方案或者调整算法细节只需要改一个类所有调用点都能自动感知。下面这个SecurePrefs工具类是 cheeseesquare 项目改造时最核心的部分。public class SecurePrefs { private static final String PREF_NAME cheesesquare_secure; private final SharedPreferences prefs; public SecurePrefs(Context context) { MasterKey masterKey new MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build(); this.prefs EncryptedSharedPreferences.create( context, PREF_NAME, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ); } public void putString(String key, String value) { prefs.edit().putString(key, value).apply(); } public String getString(String key, String defaultValue) { return prefs.getString(key, defaultValue); } public void putBoolean(String key, boolean value) { prefs.edit().putBoolean(key, value).apply(); } public boolean getBoolean(String key, boolean defaultValue) { return prefs.getBoolean(key, defaultValue); } public void remove(String key) { prefs.edit().remove(key).apply(); } public void clear() { prefs.edit().clear().apply(); } }这段代码值得解释的几个点MasterKey.Builder负责在 Android Keystore 中生成或加载主密钥。同一个应用同一套参数多次构建得到的是同一个密钥这样才能保证数据加密后还能正确解密。EncryptedSharedPreferences.create的参数里PrefKeyEncryptionScheme.AES256_SIV用于加密 keyPrefValueEncryptionScheme.AES256_GCM用于加密 value。为什么 key 和 value 用不同模式因为 key 通常很短SIV 模式在短数据加密和防篡改方面更有优势而 value 长度不定GCM 模式可以提供认证加密和完整性验证。apply()方法在异步写盘的同时会立即把数据更新到内存缓存中对于常规 UI 操作是足够的。如果你明确需要在写盘完成后拿到结果再改用commit()但这在大项目里通常不建议因为commit()是同步操作耗时较长时会造成 UI 卡顿。3.3 让老项目快速迁移透明替换策略cheesesquare 项目这种老代码最大的痛点在于调用点分散在多个 Activity 和 Fragment 里。如果逐个去改工作量大不说还容易漏掉。我建议采用透明替换策略。所谓透明替换就是保持原有SharedPreferences的获取方式不变只替换底层实现。具体做法是写一个PrefsProvider类public class PrefsProvider { private static SharedPreferences prefs; public static SharedPreferences get(Context context) { if (prefs null) { prefs new SecurePrefs(context).getSharedPreferences(); } return prefs; } }然后在所有调用点统一改成PrefsProvider.get(context).getString(...)。如果你觉得所有原生的getSharedPreferences调用点都需要动大量代码也可以在基类里做一层统一处理比如在 BaseActivity 中暴露protected SharedPreferences getSecurePrefs()让子类直接调用。在改造过程中我强烈建议分两步走先让所有调用点都切到SecurePrefs跑一遍全流程回归测试确认功能没有异常后再考虑把工具类内部的实现从原生SharedPreferences替换为EncryptedSharedPreferences。这样风险可控出了问题也能快速定位是哪一步引入的。3.4 关键代码实操数据迁移与兼容性细节替换成加密SharedPreferences之后有一个很容易被忽略的问题旧的明文数据怎么办如果你原来的项目已经在生产环境运行过用户设备上保存了明文的登录状态和用户信息直接换成加密实现后从旧文件里读出来的数据会是乱码或者解密异常因为旧数据根本没有加密过。处理方式是在SecurePrefs初始化时增加一段数据迁移逻辑private void migrateLegacyPrefs(Context context) { SharedPreferences legacy context.getSharedPreferences(legacy_prefs, Context.MODE_PRIVATE); if (legacy.contains(access_token)) { String token legacy.getString(access_token, null); if (token ! null) { prefs.edit().putString(access_token, token).apply(); legacy.edit().remove(access_token).apply(); } } }迁移时注意两点第一只迁移确实需要加密的敏感字段不要一股脑把所有数据都拷过来避免历史脏数据污染新文件第二迁移完成后记得删除旧数据否则旧文件里仍然留着明文线索。另外还要关注密钥的版本问题。Android Keystore 中生成的密钥与设备的锁屏状态、系统升级状态都可能有关联。一旦用户改变了锁屏方式或者在恢复出厂设置后从备份中恢复了应用数据可能出现密钥失效、数据无法解密的情况。这种情况下业务侧通常要设计一套“重新登录”的兜底逻辑检测到解密异常时清除本地加密数据并引导用户重新登录。4. 常见问题与排查技巧实录4.1 解密失败与 Key 丢失遇到SecurityException怎么办在实际压测和灰度过程中最常见的问题就是解密失败。表现是调用prefs.getString时抛出了SecurityException或者返回的字符串是一串无法解析的字符。原因大概率有两个方向Keystore 中的密钥失效或丢失。比如用户在系统设置里清除了应用数据或者系统更新导致 Keystore 重设了硬件密钥。原本的数据是用旧密钥加密的当前新生成的密钥无法解开旧密文。排查思路也不复杂。先看异常堆栈里是否有KeyPermanentlyInvalidatedException如果有那说明密钥已经被系统标记为永久失效唯一的办法是清掉旧数据重新生成密钥。如果没有这个异常再检查是不是数据迁移逻辑没有执行导致新旧数据互相覆盖。我建议在SecurePrefs中增加一个兜底方法专门处理解密失败的情况public String getStringSafely(String key, String defaultValue) { try { return prefs.getString(key, defaultValue); } catch (Exception e) { // 密钥失效或数据异常清除并重新初始化 reset(); return defaultValue; } }这里要注意一个度的问题不能所有字段都无脑走兜底否则就把加密的防护意义削弱了。只有那些丢了也无所谓的偏好类字段适合走兜底Token 这类关键凭证一旦解密失败应该直接走重新登录流程。4.2 版本升级后的兼容性坑加密SharedPreferences在不同版本的 Android 系统上表现并不完全一致。我在几个不同厂商的测试设备上跑过发现主要差异集中在两点。一是 Android 6.0 以下对 Keystore 的支持力度有限。虽然系统组件会在不支持硬件 Keystore 的老设备上退化为软件 Keystore但安全性会打折扣。如果项目 minSdk 放得很低建议只在关键场景使用加密 SP不要把整套业务都架在上面。二是部分厂商 ROM 对 Keystore 的备份与迁移机制实现不同。当用户开启云备份或换机迁移时加密 SP 的数据可能被恢复到了新设备上但 Keystore 中的密钥没有跟着迁移导致应用数据全部不可用。遇到这种情况单靠应用层代码很难完全解决合理的做法是在应用层增加异常检测与数据清理引导不要让用户卡在“加密数据读不出来”的死循环里。另外需要提醒的是EncryptedSharedPreferences禁用了多进程模式。如果项目里某处用了android:process:xxx并尝试跨进程访问同一个 SP 文件会出现 IllegalStateException因为加密 SP 的缓存机制是进程内的。遇到多进程需求更靠谱的方式是转而使用其他跨进程存储方案或者在网络请求层做数据同步。4.3 性能开销与 ANR 风险大家可能担心加密会引入额外的性能消耗。实测下来单条数据的加解密耗时在 1~2 毫秒左右如果只是存储 token、用户 ID 这种少量字段对用户体验几乎无感。但有两个风险点需要提醒。第一不要在读取频繁的路径上反复创建EncryptedSharedPreferences实例。每次创建实例都可能触发密钥加载和初始化这个过程如果放在循环里或者放在 getView 这种高频调用处会拖慢性能。正确做法是把实例做单例化整个应用生命周期只初始化一次。第二SharedPreferences的apply()虽然是异步写盘但加密操作本身是同步的如果一次性写入大量数据比如你把一个大的 JSON 字符串直接塞进去加解密耗时会明显增加极端情况下还会导致 ANR。我的建议是保持SharedPreferences存储的数据小而稀疏超过几十 KB 的复杂数据请改用数据库或文件存储。4.4 常见问题速查表现象可能原因处理方式解密后返回乱码新旧数据混淆或密钥不一致走迁移逻辑清空旧明文数据抛 SecurityExceptionKeystore 密钥失效清除本地数据并重新生成密钥跨进程访问异常加密 SP 不支持多进程改为单进程访问或换存储方案性能突然变差高频创建实例或存储了超大值单例化初始化控制单条数据大小从旧版本升级后数据丢失替换了 SP 文件但未做迁移在工具类中实现旧数据迁移逻辑5. 一点过来人的补充建议在 cheesesquare 项目的加密改造过程中我踩了不少坑最后总结出几条经验供大家参考。如果你准备在老项目里引入加密 SP不要试图一次性把全部SharedPreferences调用点都替换完。分模块做每替换一个模块就回归测试一轮这样才能精确定位问题。优先替换登录态、用户信息、支付凭证这类最敏感的数据偏好设置类字段可以往后放。另外无论你用哪种加密方案请务必在产品层面保留“用户主动退出登录”的入口。因为本地加密数据一旦因为密钥原因不可用用户能做的只有重新登录如果应用没有提供这个路径用户就会困在登录状态无法使用的死局里。最后再说一句加密SharedPreferences是提升 Android 应用基础安全水位的好手段但它不是灵丹妙药。它解决的是“本地静态存储泄露”这一环的风险至于网络传输层的数据安全、服务端的脱敏和风控依然要靠各自对应的体系来兜底。对于大部分中小团队来说先用最低成本把本地明文存储这件事补上已经是一次非常值得投入的安全加固了。