182 lines
5.3 KiB
Markdown
182 lines
5.3 KiB
Markdown
# 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
|