Flutter 基础体系 · 第 60/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 安全存储:Keychain、Keystore、密钥、备份和设备迁移
在 Flutter 应用中保存登录令牌、刷新令牌、设备标识或加密密钥时,shared_preferences、普通文件和 SQLite 都不能自动变成“安全存储”。它们解决的是持久化问题,而不是密钥保护问题。
所谓安全存储,至少涉及四个不同对象:
- 业务秘密:例如访问令牌、刷新令牌、API 密钥、用户隐私数据的加密密钥。
- 密码学密钥:用于加密、解密、签名或验证的密钥。
- 平台密钥容器:iOS 的 Keychain 和 Android 的 Keystore。
- 备份与迁移策略:数据能否进入备份、能否在新设备恢复、恢复后是否仍然可解密。
如果只记住“把字符串写入 flutter_secure_storage”,容易在备份恢复、卸载重装、设备迁移或系统升级时得到错误结论。安全存储的核心不是某个 Flutter API,而是下面这条数据链路:
flowchart LR
A[Flutter Dart 层] --> B[平台插件]
B --> C[iOS Keychain]
B --> D[Android Keystore]
C --> E[Keychain 项]
D --> F[不可导出的平台密钥]
F --> G[应用加密数据]
E --> H[备份/迁移策略]
G --> H
H --> I[新设备恢复]
I --> J{密钥是否仍可用}
J -->|是| K[恢复秘密]
J -->|否| L[重新认证或重新生成密钥]
一、先区分“秘密”“密钥”和“加密数据”
1. 业务秘密不一定是加密密钥
登录令牌本身通常是一个秘密字符串,但它未必用于加密其他数据。例如:
access_token = 用于访问 API 的短期令牌
refresh_token = 用于换取新 access_token 的长期令牌
db_key = 用于加密本地数据库的随机密钥
refresh_token 的泄露后果可能是账户被长期接管;db_key 的泄露后果可能是本地数据库可以被解密。二者都是敏感数据,但用途不同。
2. 加密密钥应尽量不以普通字符串形式暴露
假设应用用 AES-GCM 加密本地数据:
其中:
- :明文;
- :加密密钥;
- :随机 nonce,通常每次加密都应不同;
- :可选的附加认证数据;
- :包含密文和认证标签的结果。
安全性主要依赖于:
- 足够随机且不能被攻击者读取;
- 同一密钥下 nonce 不重复;
- 解密时认证标签必须验证成功;
- 密文和 nonce 一起保存并不等于泄露密钥。
因此,常见架构是:
平台安全容器保存 K
普通文件或数据库保存 AES-GCM 密文、nonce、版本号
但这不是绝对规则。若保存的是服务器签发的刷新令牌,可以直接把令牌作为安全存储对象;没有必要为了“加密令牌”而再设计一套本地密钥体系。
3. “加密了”不代表“密钥安全”
下面这种做法不能自动提供安全性:
final encrypted = encryptWithKey(
plaintext,
'hard-coded-key-in-source-code',
);
因为硬编码密钥会随着应用包发布。攻击者可以反编译应用、查找字符串或调试运行过程,从而同时得到算法和密钥。
更合理的密钥来源包括:
- Android Keystore 生成并持有的密钥;
- iOS Keychain 中保存的随机密钥;
- 用户输入的密码经过适当 KDF 派生出的密钥;
- 服务端通过认证流程下发、且生命周期受控的秘密。
其中“平台容器保护的密钥”与“平台容器保护的字符串”是两个层次。Keychain 能直接保存秘密;Android Keystore 更典型的能力是保存或生成不可导出的密码学密钥,再让应用用该密钥操作外部密文。
二、Keychain 是什么
1. Keychain 是 iOS/macOS 的受保护秘密存储
Apple 平台的 Keychain 是由系统管理的键值式安全存储。应用通过 Keychain Services API 创建和读取 Keychain item,而不是直接访问某个普通文件。
一个 Keychain item 通常包含:
服务标识 service
账户标识 account
秘密数据 data
访问控制属性 accessibility / access control
在 Flutter 中,插件一般把字符串映射成一个 Keychain item。例如:
await storage.write(
key: 'refresh_token',
value: token,
);
逻辑上可以理解为:
(service, account = refresh_token) -> token
实际的 service、分组和访问属性由插件实现及配置决定,不应把插件内部映射当成 Apple API 的固定语义。
2. Keychain 不是“应用私有普通文件”
Keychain 数据由系统服务管理,应用不能像读取沙盒文件那样直接遍历全部内容。应用只能访问满足查询条件的项目。
这带来两个重要结果:
- 删除普通应用沙盒文件,不一定删除 Keychain item;
- 卸载并重新安装应用后,某些 Keychain item 可能仍然存在。
因此,下面的判断并不可靠:
卸载应用 = 所有本地凭据都消失
如果产品要求“卸载后再次安装必须视为新安装”,应在设计中明确处理 Keychain 残留,而不能假设系统一定清理。
3. Keychain accessibility 决定何时可用
Keychain item 可以设置不同的可访问性。例如,常见概念包括:
- 设备解锁后可访问;
- 首次解锁后可访问;
- 仅当前设备可访问;
- 只有设置锁屏密码时才可访问;
- 允许随设备迁移,或不允许迁移。
以 Flutter 插件常见的 API 形态为例:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const storage = FlutterSecureStorage();
Future<void> saveToken(String token) async {
await storage.write(
key: 'refresh_token',
value: token,
iOptions: const IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);
}
这里的 first_unlock 表示:设备启动后,用户第一次解锁之前,后台进程通常不能访问该项目;第一次解锁之后,系统允许符合条件的后台访问。
这与 when_unlocked 的语义不同。若令牌只在用户打开应用并解锁设备后使用,较严格的“设备解锁时才可访问”通常更符合最小权限原则;若后台任务必须在首次解锁后运行,则可能需要更宽松的访问时机。
4. ThisDeviceOnly 会改变迁移行为
“只限本设备”的 Keychain accessibility 会阻止该项目迁移到其他设备。它适合以下秘密:
- 绑定当前设备的私钥;
- 不应随备份复制的设备身份;
- 一旦迁移就必须重新注册的凭据。
但它也意味着:
旧设备可用
备份恢复到新设备
新设备读取失败或没有该项目
这不是存储损坏,而是迁移策略生效。
三、Keystore 是什么
1. Android Keystore 是密钥管理系统,不等同于普通键值数据库
Android Keystore 的核心对象是密码学密钥,例如用于:
- AES 加密和解密;
- RSA 或 EC 签名;
- 公钥验证;
- 密钥协商。
它的关键性质是:应用通常不能以普通字节数组形式导出由 Keystore 生成的私钥或对称密钥。应用只能请求系统执行:
使用别名 alias 对数据加密
使用别名 alias 对数据解密
使用别名 alias 对消息签名
因此,下面两个概念必须分开:
Keystore:保存或生成密码学密钥
SharedPreferences / 文件 / 数据库:保存普通应用数据
Android Keystore 本身不是任意字符串的通用数据库。
2. 硬件支持不等于所有设备都使用硬件安全模块
部分 Android 设备提供 TEE 或 StrongBox 等硬件隔离能力。应用可以请求某些密钥在硬件支持的环境中生成或使用,但实际能力取决于:
- 设备硬件;
- Android 版本;
- 密钥算法和参数;
- 厂商实现;
- 当前安全状态。
不能把“使用 Android Keystore”表述为“密钥一定在独立硬件芯片中”。更准确的表述是:
Android Keystore 提供系统管理的密钥接口;在支持的设备和配置下,密钥操作可能由硬件支持的安全环境完成。
3. 密钥别名是定位符,不是密钥本身
例如:
alias = com.example.app.database-key
这个字符串只是系统中的查找名称。攻击者知道 alias 并不等于能够导出密钥,但如果攻击者已经能在应用进程内执行任意代码,仍可能调用应用已有的解密路径。因此,Keystore 主要降低“直接提取密钥材料”的风险,不能阻止已被完全控制的运行中应用使用密钥。
4. Android 密钥可能与设备状态绑定
某些密钥可以要求:
- 用户设置安全锁屏;
- 用户认证后才能使用;
- 设备处于特定安全状态;
- 密钥只能在生成它的设备环境中使用。
当设备恢复、系统升级、锁屏策略变化或密钥被删除时,使用密钥可能抛出异常。应用必须把“密钥不存在”和“密钥存在但不可用”视为正常故障分支,而不能无限重试。
四、Flutter 中使用安全存储的端到端示例
Flutter SDK 没有规定一个跨平台的安全存储实现。工程中常用社区插件,例如 flutter_secure_storage。它不是 Flutter SDK 内置 API,因此应把插件版本、平台实现和变更日志纳入发布验证。
1. 添加依赖
在 pubspec.yaml 中声明依赖,版本号应选择项目实际验证过的版本:
dependencies:
flutter:
sdk: flutter
flutter_secure_storage: ^待项目验证的版本
实际项目不能保留“待项目验证的版本”这样的占位符;这里不指定版本,是为了避免把某个版本误称为当前稳定版本。执行:
flutter pub get
预期结果是依赖解析成功,并在 pubspec.lock 中记录实际版本。
2. 创建一个最小存储封装
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecretStore {
SecretStore()
: _storage = const FlutterSecureStorage();
final FlutterSecureStorage _storage;
Future<void> saveRefreshToken(String token) async {
if (token.isEmpty) {
throw ArgumentError.value(token, 'token', '不能为空');
}
await _storage.write(
key: 'refresh_token',
value: token,
iOptions: const IOSOptions(
// 设备首次解锁后可用于后台访问。
// 如果业务不需要后台访问,可评估更严格的 when_unlocked。
accessibility: KeychainAccessibility.first_unlock,
),
);
}
Future<String?> readRefreshToken() {
return _storage.read(key: 'refresh_token');
}
Future<void> deleteRefreshToken() {
return _storage.delete(key: 'refresh_token');
}
}
这个封装有三个可观察结果:
final store = SecretStore();
await store.saveRefreshToken('r1');
assert(await store.readRefreshToken() == 'r1');
await store.deleteRefreshToken();
assert(await store.readRefreshToken() == null);
read 返回 null 通常表示没有对应项目,但平台错误、解密失败或密钥失效也可能以异常形式出现。生产代码应记录错误类别和上下文,但不能把令牌本身写入日志。
3. Android 侧的核心语义
在 Android 上,插件通常会组合使用 Android Keystore 和应用数据存储:Keystore 负责保护密钥,应用数据层保存经过保护的数据或密钥引用。具体实现随插件版本变化,不能仅凭 Dart API 推断底层一定是某一种 SharedPreferences 或某一种加密方案。
调用时应考虑以下异常路径:
Future<String?> readTokenSafely(SecretStore store) async {
try {
return await store.readRefreshToken();
} on Exception catch (error) {
// 生产环境记录平台错误类型、应用版本、Android 版本等。
// 不记录 token、密钥或完整异常中的敏感内容。
rethrow;
}
}
如果恢复后的密文依赖一个未恢复的 Keystore 密钥,读取可能失败。此时正确流程通常是:
读取失败
↓
判断是否属于密钥失效/不可用
↓
删除无效密文和旧密钥引用
↓
要求用户重新登录或重新建立设备密钥
不能直接生成一个新密钥后继续解密旧密文,因为新密钥无法解开旧密文;这样做只会把真实的数据丢失隐藏成“读取为空”。
五、备份:保存了数据,不代表保存了密钥
备份问题最容易产生错误直觉。假设 Android 应用有:
D = 用 AES 密钥 K 加密的本地数据
K = Android Keystore 中的密钥
如果备份系统只复制了 ,没有复制或无法恢复 ,那么新设备上只能得到:
解密函数:
因为 不存在,无法得到 。备份成功不等于数据可恢复。
1. Android Auto Backup 的典型失败路径
Android 的应用备份策略、系统版本、厂商实现和应用配置都会影响哪些文件被备份。常见失败路径如下:
sequenceDiagram
participant Old as 旧设备
participant Backup as 备份服务
participant New as 新设备
participant App as 应用
participant KS as 新设备 Keystore
Old->>App: 生成密钥 K_old
App->>Old: 保存密文 D = Enc(K_old, P)
Old->>Backup: 备份应用数据 D
Backup->>New: 恢复 D
App->>KS: 查找 alias
KS-->>App: 没有 K_old,或 K_old 不可用
App->>App: 解密失败
App-->>New: 重新认证/清理失效数据
可能表现为:
read抛出平台异常;- 加密库报告认证失败;
- 应用误判为“用户没有登录”;
- 应用不断生成新密钥,导致旧数据永久无法读取;
- 恢复后启动崩溃。
2. 备份策略有两个相反方向
方向 A:不备份敏感数据
如果令牌可以通过重新登录获得,最简单的策略是:
不备份令牌
不备份依赖设备密钥的密文
新设备重新登录
这牺牲了迁移便利性,换取更清晰的安全边界。
Android 项目可以通过备份规则排除相关文件。具体 XML 文件名、放置位置和属性取决于项目的 targetSdk、Android Gradle Plugin 以及采用的备份机制,应以当前 Android 文档和构建产物验证。一个示意性的规则结构如下:
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup [disableIfNoEncryptionCapabilities="true"]>
<include domain="file" path="." requireFlags="clientSideEncryption" />
<exclude domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
上面的内容不是可以无条件复制的通用配置。[disableIfNoEncryptionCapabilities="true"] 只是说明某些属性需要按实际 Android 配置填写,不能把方括号保留到正式 XML 中。部署前必须:
- 确认应用最终使用的备份规则格式;
- 确认
domain对应的实际存储位置; - 构建 APK/AAB 并检查合并后的资源;
- 在真实或受控设备上执行备份、恢复和读取测试。
方向 B:允许数据备份,但把密钥设计为可迁移
如果业务要求用户换机后无感恢复,不能只把密文纳入备份,还要解决密钥迁移问题。常见方案包括:
- 使用平台允许迁移的 Keychain 项;
- 使用用户密码派生密钥,用户在新设备输入密码;
- 使用服务端账户体系重新下发或重建本地密钥;
- 使用端到端加密设计,让恢复密钥由用户或服务端的另一条信任链保护。
每个方案都改变了威胁模型。例如,使用用户密码恢复密钥要求密码具备足够熵,并使用 Argon2id、scrypt 或 PBKDF2 等适当的 KDF;仅执行一次 SHA-256 不是安全的密码存储方案。
六、iOS 的备份与迁移
1. Keychain 迁移取决于访问属性和系统策略
iOS Keychain 项目是否能在备份恢复或设备迁移后继续使用,不应简单归纳为“会迁移”或“不会迁移”。影响因素包括:
- Keychain accessibility;
- 是否使用
ThisDeviceOnly; - 备份类型;
- 系统版本和迁移路径;
- 应用的 Keychain access group;
- 设备是否具备解锁密码;
- 项目是否在新设备上以相同应用身份访问。
尤其是带 ThisDeviceOnly 语义的项目,设计目的就是不随设备迁移。它适合设备绑定密钥,但不适合要求换机后自动恢复的登录凭据。
2. “Keychain 持久化”与“账户迁移”不是一回事
即使新设备成功恢复了某个 Keychain item,也不表示服务器会接受其中的令牌。服务器可能因为以下原因拒绝:
- 令牌已过期;
- 令牌被撤销;
- 设备绑定信息变化;
- 风险策略要求重新认证;
- 服务端检测到异常地理位置或设备状态。
因此,设备迁移流程应把本地恢复和服务端认证分开:
本地读到 refresh_token
↓
向服务端刷新
↓
服务端接受:继续会话
服务端拒绝:清理本地令牌并要求登录
成功读取本地字符串,只证明存储层可读,不证明身份仍然有效。
七、备份、同步、导出和迁移是四种不同操作
这些词经常被混用,但安全含义完全不同。
1. 备份
备份是把应用数据复制到备份介质。备份可能被加密,也可能受系统账户和设备密码保护。应用不能仅凭“系统提供备份”推断所有秘密都能恢复。
2. 同步
同步是多个设备之间共享数据。同步意味着数据会离开当前设备,通常需要服务端、云存储或用户控制的传输协议。对刷新令牌进行同步,等价于扩大令牌泄露面,必须重新评估会话模型。
3. 导出
导出是用户或应用主动生成可携带文件。若导出包含密钥或可解密的密文,必须有独立保护,例如:
用户输入导出密码
↓
KDF(password, salt, cost) -> K_export
↓
AEAD(K_export, exported_data) -> export_file
导出密码不能直接作为 AES 密钥,因为人类密码通常熵不足。
4. 设备迁移
设备迁移是把账户身份或应用状态带到新设备。更稳健的迁移策略通常不是复制旧设备的全部秘密,而是:
旧设备确认用户身份
↓
新设备建立新的设备密钥
↓
服务端为新设备签发新的令牌
↓
旧设备密钥按策略撤销或保留
这能避免把“旧设备的设备绑定密钥”错误地当作“新设备的密钥”。
八、密钥生命周期:生成、使用、轮换和销毁
1. 生成
密码学密钥必须来自密码学安全随机源。不要使用:
final key = DateTime.now().millisecondsSinceEpoch.toString();
时间戳可预测,不能承担密钥生成职责。应使用平台 Keystore、Keychain 或 Dart/原生密码学库提供的安全随机源。
2. 使用
每个密钥应有明确用途:
K_db 只用于数据库加密
K_export 只用于导出文件
K_signing 只用于签名
refresh_token 只用于刷新会话
一个密钥承担多个互不相关的用途,会增加协议耦合和轮换困难。
3. 轮换
假设应用从旧密钥 轮换到新密钥 ,不能直接删除 。安全迁移需要:
- 使用 解密旧数据;
- 验证认证标签;
- 使用 重新加密;
- 原子地写入新版本;
- 确认新数据可读;
- 再删除 。
伪代码如下:
Future<void> rotateDatabaseKey() async {
final oldPlaintext = await decryptWithOldKey();
final newCiphertext = await encryptWithNewKey(oldPlaintext);
await writeVersionedCiphertext(
version: 2,
ciphertext: newCiphertext,
);
await verifyVersionedCiphertext(2);
await deleteOldKeyIfNoLongerNeeded();
}
若在第 3 步后进程崩溃,旧数据仍应可读;若在第 4 步写入不完整,应通过临时文件、校验和或数据库事务恢复。
4. 销毁
删除安全存储项目通常意味着删除应用可访问的记录或密钥别名,但不应把它表述为“所有历史介质上的字节都被物理抹除”。闪存磨损均衡、备份副本、日志和系统内部实现都可能使彻底物理擦除无法由应用保证。
业务上更重要的是:
删除本地令牌
撤销服务端会话
删除或废弃本地密钥
清理依赖该密钥的密文
只删除令牌而不撤销服务端会话,不能保证旧令牌立即失效。
九、并发、生命周期和原子性
1. 异步 API 不等于事务
下面的代码存在典型的读改写竞争:
final value = await storage.read(key: 'counter');
final next = (int.tryParse(value ?? '0') ?? 0) + 1;
await storage.write(key: 'counter', value: '$next');
如果两个异步任务同时执行:
任务 A 读到 0
任务 B 读到 0
任务 A 写入 1
任务 B 写入 1
最终值是 1,而不是预期的 2。
安全存储插件通常提供单次读写操作,但不自动提供跨多次调用的事务。需要复合操作时,应在应用层串行化:
class SerializedSecretStore {
SerializedSecretStore(this._storage);
final FlutterSecureStorage _storage;
Future<void> _tail = Future<void>.value();
Future<T> runSerialized<T>(Future<T> Function() action) {
final result = _tail.then((_) => action());
_tail = result.then<void>(
(_) {},
onError: (Object error, StackTrace stack) {},
);
return result;
}
}
这个示例只保证同一个 SerializedSecretStore 实例中的调用排队。多个实例、多个 isolate 或多个进程之间是否有同样的顺序保证,取决于平台和插件实现,不能仅靠这段代码推断。
2. 初始化与退出时机
安全存储访问是异步平台调用。应避免在 Flutter binding 尚未初始化时触发依赖插件的启动逻辑:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final store = SecretStore();
final token = await store.readRefreshToken();
runApp(MyApp(initialToken: token));
}
但也不要因为读取令牌失败就让整个应用永久无法启动。更合理的状态是:
loading
├── token available -> refreshing / authenticated
├── token absent -> unauthenticated
└── storage error -> recoverable error / reauthentication
“没有令牌”和“安全存储损坏”不是同一个状态。
3. 写入令牌时避免半成品状态
刷新令牌流程常见的错误是先删除旧令牌,再写入新令牌:
删除旧 token
网络请求
写入新 token
如果应用在网络请求后崩溃,可能留下空状态。通常应先完成服务端刷新,再一次性写入新值;若服务端采用令牌轮换,则需要根据服务端协议设计短暂的旧值/新值过渡,而不是自行猜测。
十、Android、iOS、桌面和 Web 的差异
1. Android
主要安全组件是 Android Keystore,以及由插件实现的应用层加密存储。需要重点验证:
- 备份规则是否包含或排除了相关数据;
- 新设备是否拥有旧 Keystore 密钥;
- 锁屏认证变化是否影响密钥;
- 卸载重装后密钥和应用数据是否仍存在;
- 不同 Android 版本和厂商设备的行为。
“Android 上使用 Keystore”不等于“数据一定可迁移”。
2. iOS
主要安全组件是 Keychain。需要重点验证:
accessibility是否满足后台和锁屏场景;- 是否使用
ThisDeviceOnly; - Keychain access group 是否一致;
- 卸载重装、备份恢复、设备迁移后的读取结果;
- 设备没有设置密码时,选择的访问级别是否仍然可用。
3. macOS
macOS 通常通过 Keychain 等系统机制提供安全存储,但桌面系统的用户权限、钥匙串锁定状态、应用签名和沙盒配置会影响访问。不能直接把 iOS 的行为复制到 macOS。发布前应测试:
- 沙盒开启和关闭时的访问;
- 应用签名变化;
- 钥匙串锁定与用户会话变化;
- 应用卸载和重新安装;
- 多用户账户之间是否隔离。
4. Windows 和 Linux
桌面插件可能使用 Windows Credential Manager、DPAPI、Linux Secret Service、libsecret 或其他系统机制。Linux 还可能没有运行可用的密钥环服务,导致:
- 写入失败;
- 读取时要求桌面会话解锁;
- 无图形会话的 CI 或后台环境无法使用;
- 不同桌面环境表现不一致。
因此,桌面应用应提供明确的错误提示和降级策略,但不能把秘密悄悄写入普通文件作为“兼容方案”,除非产品明确接受更弱的安全模型。
5. Web
浏览器没有 Android Keystore 或 iOS Keychain。Web 安全存储通常依赖浏览器提供的 Web Crypto、IndexedDB、Storage API 或插件的 Web 实现,安全边界完全不同:
- 页面 JavaScript 在同一源下可能访问应用数据;
- XSS 可能直接读取运行时令牌;
- 浏览器扩展、恶意脚本和调试工具改变威胁模型;
- 清理站点数据会删除本地数据;
- 私密浏览模式和不同浏览器行为可能不同;
- 跨设备迁移通常依赖账户同步,而不是设备级 Keychain/Keystore。
Web 应优先考虑 HttpOnly、Secure、SameSite Cookie 等浏览器会话机制,并结合 CSRF 防护;不能把移动端安全存储插件的 API 形状误认为 Web 具有相同的硬件或系统保护。
十一、常见误解与对应故障
误解一:flutter_secure_storage 可以抵抗设备完全被控制
不能。若设备已经 root、越狱,或攻击者能够调试和控制应用进程,攻击者可能在应用使用秘密的瞬间截获明文。平台容器主要降低离线提取和直接读取存储材料的风险。
误解二:加密数据进入备份后,新设备一定能解密
不一定。密文 和密钥 必须同时可用,且密钥的访问条件在新设备上仍需满足。只恢复 会产生“文件在,但数据不可读”的状态。
误解三:删除应用就会删除全部凭据
iOS Keychain 项目可能在卸载后保留;Android 的数据和 Keystore 行为也受系统版本、卸载方式和恢复机制影响。注销流程应主动删除应用可访问的项目,并请求服务端撤销会话。
误解四:读取返回 null 就代表用户从未登录
null 只能说明当前查询没有返回值。它可能表示:
- 从未写入;
- 用户已注销;
- 数据被系统清理;
- 应用换了 service 或 account;
- 恢复过程没有带回项目;
- 迁移策略禁止访问;
- 插件或平台配置发生变化。
若出现平台异常,则还可能是密钥失效或认证条件不满足。
误解五:把所有本地数据都放进安全存储就是最安全
安全容器适合少量高价值秘密。把大型数据库、缓存、图片和普通配置全部放进去,可能导致:
- 读写性能和可靠性下降;
- 迁移策略变复杂;
- 密钥轮换困难;
- 任何一个令牌泄露都扩大影响范围。
更清晰的设计通常是:
安全存储:refresh_token、数据库密钥、设备私钥
普通存储:加密后的业务数据、缓存、非敏感配置
服务端:会话撤销、账户状态、设备注册关系
十二、诊断方法:先定位是哪一层失败
遇到“安全存储读不到”时,不要直接删除所有数据。可以按层定位:
第一步:确认 Dart 层参数一致
检查写入和读取是否使用完全相同的:
key
service
account
access group
尤其是 iOS 的 bundle identifier、Keychain access group 或插件配置变化,可能导致“写入成功但读取为空”。
第二步:区分空值和平台异常
try {
final token = await store.readRefreshToken();
if (token == null) {
// 没有项目:进入未登录或迁移检查流程
} else {
// 得到本地 token,再向服务端验证
}
} catch (error, stackTrace) {
// 记录脱敏后的错误上下文
// 不要打印 token、密钥或完整敏感 payload
}
空值和异常应进入不同的状态机分支。
第三步:检查设备状态
需要记录但脱敏的诊断信息包括:
- 平台和系统版本;
- 应用版本;
- 是否从备份恢复;
- 是否发生设备迁移;
- 是否刚刚卸载重装;
- 是否修改了锁屏密码;
- 是否更换了应用签名或构建环境;
- 是否使用了不同的 Keychain access group。
第四步:验证备份规则和密钥存在性
Android 应检查最终合并出的备份配置,而不是只看源码中的某个 XML。iOS 应检查 Keychain accessibility、迁移属性和签名配置。
如果密文存在但密钥不存在,恢复策略只能是:
保留诊断信息
删除不可恢复的密文
清理旧引用
重新登录或重新建立密钥
不要通过反复重试或无条件生成新密钥掩盖数据不可恢复的事实。
十三、按业务目标选择迁移策略
场景一:普通登录会话
目标是安全,而不是无条件换机免登录:
旧设备令牌不迁移
新设备重新登录
服务端签发新令牌
这是最容易验证的策略。
场景二:用户希望换机后恢复加密数据
需要引入独立的恢复信任根:
账户认证 / 用户恢复密码 / 旧设备确认
↓
新设备生成 K_new
↓
使用恢复信任根解开旧数据
↓
重新加密为 Enc(K_new, P)
不能把旧设备专属密钥直接复制成新设备密钥。
场景三:设备绑定凭据
选择不迁移的 Keychain 项或设备绑定的 Android Keystore 密钥:
旧设备密钥 K_old 只在旧设备可用
新设备生成 K_new
服务端登记新设备
旧设备按撤销策略处理
这种策略更适合支付签名、设备证明或高风险操作,而不是普通用户偏好设置。
十四、发布前必须验证的完整流程
安全存储功能至少应覆盖以下实际测试,而不是只测试一次 write/read:
- 首次安装后写入并读取;
- 应用重启后读取;
- 锁屏、解锁、设备重启后读取;
- 注销后确认本地秘密删除;
- 卸载重装后确认产品期望的行为;
- Android 备份恢复后读取;
- iOS 备份或设备迁移后读取;
- 修改锁屏密码后的读取;
- Keychain/Keystore 项不存在时的恢复;
- 密文存在但密钥不存在时的恢复;
- 并发刷新令牌;
- 网络刷新成功但应用在写入前崩溃;
- Web 清理站点数据;
- Linux 没有可用 Secret Service 时的错误提示;
- 应用签名、Bundle ID 或 Keychain group 变化后的升级路径。
每个测试都应记录预期状态,而不是只记录“是否抛异常”。例如:
状态:密文存在,Keystore 密钥缺失
预期:清理失效密文,进入重新认证
禁止:无限重试
禁止:把新密钥当作旧密钥解密
结语
Keychain 和 Keystore 都是平台安全能力,但职责并不相同:
- Keychain 更接近系统管理的秘密存储,访问时机和设备迁移属性由 Keychain item 的配置影响;
- Keystore 主要管理不可导出的密码学密钥,应用数据通常仍需保存在其他位置;
- 密钥 决定加密数据是否可恢复,密文备份与密钥备份必须一起设计;
- 备份 不等于迁移,迁移也不等于服务端会话继续有效;
- Flutter 插件 提供跨平台调用入口,但不能抹平平台安全模型和故障差异。
一个可验证的设计应明确回答四个问题:
秘密保存在哪里?
保护它的密钥保存在哪里?
备份或换机后是否允许恢复?
恢复失败时,应用和服务端如何重新建立信任?
如果这四个问题都有具体状态、故障路径和恢复方案,安全存储才真正成为系统设计的一部分,而不是一个隐藏在 Dart API 后面的字符串读写调用。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter SQLite 与 Drift:Schema、查询、事务、迁移和响应式数据
- 下一篇:Flutter 文件上传:选择、分片、进度、取消、后台和断点续传
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论