Flutter 基础体系 · 第 60/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 安全存储:Keychain、Keystore、密钥、备份和设备迁移

在 Flutter 应用中保存登录令牌、刷新令牌、设备标识或加密密钥时,shared_preferences、普通文件和 SQLite 都不能自动变成“安全存储”。它们解决的是持久化问题,而不是密钥保护问题。

所谓安全存储,至少涉及四个不同对象:

  1. 业务秘密:例如访问令牌、刷新令牌、API 密钥、用户隐私数据的加密密钥。
  2. 密码学密钥:用于加密、解密、签名或验证的密钥。
  3. 平台密钥容器:iOS 的 Keychain 和 Android 的 Keystore。
  4. 备份与迁移策略:数据能否进入备份、能否在新设备恢复、恢复后是否仍然可解密。

如果只记住“把字符串写入 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 加密本地数据:

C=AES-GCM(K,N,P,A)C = \operatorname{AES\text{-}GCM}(K, N, P, A)

其中:

  • PP:明文;
  • KK:加密密钥;
  • NN:随机 nonce,通常每次加密都应不同;
  • AA:可选的附加认证数据;
  • CC:包含密文和认证标签的结果。

安全性主要依赖于:

  1. KK 足够随机且不能被攻击者读取;
  2. 同一密钥下 nonce 不重复;
  3. 解密时认证标签必须验证成功;
  4. 密文和 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 中的密钥

如果备份系统只复制了 DD,没有复制或无法恢复 KK,那么新设备上只能得到:

D 存在,K 不存在D \text{ 存在},\quad K \text{ 不存在}

解密函数:

P=AES-GCM1(K,D)P = \operatorname{AES\text{-}GCM}^{-1}(K, D)

因为 KK 不存在,无法得到 PP。备份成功不等于数据可恢复。

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 中。部署前必须:

  1. 确认应用最终使用的备份规则格式;
  2. 确认 domain 对应的实际存储位置;
  3. 构建 APK/AAB 并检查合并后的资源;
  4. 在真实或受控设备上执行备份、恢复和读取测试。

方向 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. 轮换

假设应用从旧密钥 K1K_1 轮换到新密钥 K2K_2,不能直接删除 K1K_1。安全迁移需要:

  1. 使用 K1K_1 解密旧数据;
  2. 验证认证标签;
  3. 使用 K2K_2 重新加密;
  4. 原子地写入新版本;
  5. 确认新数据可读;
  6. 再删除 K1K_1

伪代码如下:

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、越狱,或攻击者能够调试和控制应用进程,攻击者可能在应用使用秘密的瞬间截获明文。平台容器主要降低离线提取和直接读取存储材料的风险。

误解二:加密数据进入备份后,新设备一定能解密

不一定。密文 DD 和密钥 KK 必须同时可用,且密钥的访问条件在新设备上仍需满足。只恢复 DD 会产生“文件在,但数据不可读”的状态。

误解三:删除应用就会删除全部凭据

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

  1. 首次安装后写入并读取;
  2. 应用重启后读取;
  3. 锁屏、解锁、设备重启后读取;
  4. 注销后确认本地秘密删除;
  5. 卸载重装后确认产品期望的行为;
  6. Android 备份恢复后读取;
  7. iOS 备份或设备迁移后读取;
  8. 修改锁屏密码后的读取;
  9. Keychain/Keystore 项不存在时的恢复;
  10. 密文存在但密钥不存在时的恢复;
  11. 并发刷新令牌;
  12. 网络刷新成功但应用在写入前崩溃;
  13. Web 清理站点数据;
  14. Linux 没有可用 Secret Service 时的错误提示;
  15. 应用签名、Bundle ID 或 Keychain group 变化后的升级路径。

每个测试都应记录预期状态,而不是只记录“是否抛异常”。例如:

状态:密文存在,Keystore 密钥缺失
预期:清理失效密文,进入重新认证
禁止:无限重试
禁止:把新密钥当作旧密钥解密

结语

Keychain 和 Keystore 都是平台安全能力,但职责并不相同:

  • Keychain 更接近系统管理的秘密存储,访问时机和设备迁移属性由 Keychain item 的配置影响;
  • Keystore 主要管理不可导出的密码学密钥,应用数据通常仍需保存在其他位置;
  • 密钥 决定加密数据是否可恢复,密文备份与密钥备份必须一起设计;
  • 备份 不等于迁移,迁移也不等于服务端会话继续有效;
  • Flutter 插件 提供跨平台调用入口,但不能抹平平台安全模型和故障差异。

一个可验证的设计应明确回答四个问题:

秘密保存在哪里?
保护它的密钥保存在哪里?
备份或换机后是否允许恢复?
恢复失败时,应用和服务端如何重新建立信任?

如果这四个问题都有具体状态、故障路径和恢复方案,安全存储才真正成为系统设计的一部分,而不是一个隐藏在 Dart API 后面的字符串读写调用。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。