5.3 KiB
5.3 KiB
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 文件也可能保留明文副本
根因
根因 1:对 FlutterSecureStorage 强制套了 400ms 超时
旧逻辑:
- secure storage 读写只要超过
400ms,就视为失败 - 一旦失败,直接退化成“仅内存”
结果:
- 当前进程内看起来一切正常
- 因为值实际上没持久化,进程退出后凭证全部丢失
这是这次“重启后 token 消失”的直接根因。
根因 2:secure storage 失败时降级策略设计错了
旧策略把“可恢复 secret”误当成“会话临时缓存”处理:
- token / password / API key 没写进 durable fallback
- 只保存在进程内存
这个策略对调试场景看似友好,但对桌面 App 的真实使用是灾难性的,因为用户天然预期“已经保存”的 secret 会跨重启存在。
根因 3:legacy 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