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

182 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 消失”的直接根因。
## 根因 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