xworkmate-app/docs/cases/secure-local-persistence-postmortem.md

5.3 KiB
Raw Blame History

Secure Local Persistence Postmortem

问题摘要

用户现场反馈很直接:

  • 当前会话里 Gateway 可以正常连接
  • App 一重启,本地配置和已保存凭证丢失
  • Gateway 访问 页重新出现 gateway token missing

这不是单点 bug而是持久层设计里连续几处降级路径叠加后的结果。

用户可见症状

1. 重启后网关凭证丢失

表现:

  • token / password 在当前会话内可用
  • 退出再打开后不可用
  • 首次连接提示重新输入 shared token

2. 本地配置或任务会话恢复不稳定

表现:

  • settings snapshot 或 assistant threads 在某些路径下恢复失败
  • backup 虽然存在,但仍可能是明文旧格式

3. 明文本地状态残留

表现:

  • 旧版 SharedPreferences 和 SQLite 中存在明文 settings / threads
  • backup 文件也可能保留明文副本

根因

根因 1FlutterSecureStorage 强制套了 400ms 超时

旧逻辑:

  • secure storage 读写只要超过 400ms,就视为失败
  • 一旦失败,直接退化成“仅内存”

结果:

  • 当前进程内看起来一切正常
  • 因为值实际上没持久化,进程退出后凭证全部丢失

这是这次“重启后 token 消失”的直接根因。

根因 2secure storage 失败时降级策略设计错了

旧策略把“可恢复 secret”误当成“会话临时缓存”处理

  • token / password / API key 没写进 durable fallback
  • 只保存在进程内存

这个策略对调试场景看似友好,但对桌面 App 的真实使用是灾难性的,因为用户天然预期“已经保存”的 secret 会跨重启存在。

根因 3legacy prefs 迁移把明文直接写回了主存储

迁移链路里存在一个关键缺口:

  • SharedPreferences 读取到旧版明文 settings / threads
  • 直接调用数据库写入
  • 没有经过 sealed local state

结果:

  • 用户完成升级后,本地状态仍可能继续以明文形式存在 SQLite
  • 旧的 pref key 也没有被及时清理

这让“升级到新版本后自动变安全”的承诺失效了。

根因 4本地状态密钥也被允许走普通 fallback

旧版把 xworkmate.local_state.key 当成普通 secret 处理。

结果:

  • 一旦它掉进 fallback 文件secure storage 就不再是本地状态加密的真正前提
  • 架构上变成“有 secure storage 更好,没有也能常态运行”

这违背了本次补丁要建立的安全模型。

根因 5线程状态异步保存存在覆盖竞态

Assistant 线程会话是异步落盘的。旧逻辑没有串行 flush

  • 线程 A 的旧快照可能在稍后写入
  • 覆盖线程 B 或更新后的新状态

在加密封装增加写入成本后,这个竞态更容易暴露。

修复策略

1. secure storage 不再 400ms 即判死刑

  • 超时提高到 5s
  • 对真实 FlutterSecureStorage 保留超时保护
  • 对测试注入 client 不套这层超时

2. secure storage 失败时改为 durable fallback而不是仅内存

  • Gateway token
  • Gateway password
  • AI Gateway API key
  • Vault token

这些 secret 在 secure storage 异常时会写入持久化 fallback保证跨实例恢复。

3. 本地配置和任务会话统一 sealed

对以下状态统一改为 AES-GCM sealed payload

  • SettingsSnapshot
  • Assistant thread records
  • assistant-state-backup.json

目标是消除明文 SQLite / 明文 JSON backup。

4. legacy 明文状态迁移时立即重写并清理旧 pref

新逻辑:

  • 读旧 pref
  • 若目标存储不存在,则按 sealed 路径写入
  • 写入成功后删除旧 pref key

这样升级后不会继续遗留明文主副本。

5. 本地状态密钥升级为 primary secure storage only

xworkmate.local_state.key 现在的规则是:

  • 必须优先保存在主 secure storage
  • 不再纳入普通 secure fallback 白名单
  • 对旧版 local-state-key.txt 仅做一次迁移,随后删除

6. Assistant 线程持久化改为串行队列

新增线程持久化 queue 和 flush 机制,保证:

  • 新状态不会被晚到的旧写入覆盖
  • clear / send / view-mode 切换前可以先 flush

7. dispose 后的异步通知保护

SettingsController 新增 dispose guard避免恢复链路异步完成后向已销毁对象 notifyListeners()

为什么旧方案会失效

旧方案的问题不在“没加密”,而在于它把三件不同的事混在了一起:

  • 当前请求是否可用
  • 是否已经持久化
  • 是否已经安全持久化

一旦 secure storage 稍慢,系统就会把“当前连接可继续”错误地当成“数据已经保存”,这正是桌面应用里最危险的误导。

回归防线

这次新增的回归覆盖重点包括:

  • secure storage 超时后 secret 仍能跨实例恢复
  • SQLite 不可用时sealed 的 settings / threads 仍能恢复
  • plaintext local state 能迁移为 sealed storage
  • legacy local-state-key.txt 能迁移到主 secure storage 并被清理
  • backup 文件不再泄露明文 settings / threads

后续约束

后续所有涉及本地状态持久化的修改,都必须继续满足:

  • .env 仍是预填,不是持久化真值
  • 当前用户发起连接时可直接用表单值握手,不依赖 secure-store 回读
  • local state 不得重新落回 SharedPreferences 明文
  • backup 不得重新变成明文副本
  • 不能再让 xworkmate.local_state.key 走常态文件 fallback