# 密钥迁移健壮性设计 > **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。 > > 背景:R-PD-1(全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`,**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。 > 状态:✅ **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15 --- ## 一、问题复现:精确触发条件 ### 1.1 触发链路 前置条件(**未迁移态**): - 历史 DB:`ai_providers.api_key` 列存有明文密钥(FR-S1 之前的老数据)。 - keyring:对应 `provider_id` 无 entry(迁移未成功,或启动迁移被跳过/失败)。 - 即「DB 有明文、keyring 空」的双源不一致态。 操作: 1. 用户进入「设置 → 提供商」,点编辑某 provider。 2. 仅修改 `name` / `base_url`(**不重新填 `api_key`**)。 3. 前端按约定把空 `api_key` 字段传给 IPC(约定:空 = 不改密钥)。 4. 后端 `ai_save_provider` 命中空 `api_key` 分支 → 不写 keyring → `record.api_key = String::new()` → `INSERT OR REPLACE` 全字段覆盖。 ### 1.2 keyring / DB 状态时序 ``` DB.api_key keyring ───────────────────────────────────────────── T0 初始(老明文) "sk-real" (空) T1 编辑提交空 key → commands.rs:334 record.api_key=String::new() T2 INSERT OR REPLACE "sk-real" 覆盖为 "" (仍空) T3 resolve_provider_secret record.api_key 空 → fallback keyring → 仍空 T4 build_provider_for → ensure_resolved_key → Err「未读取到密钥」 T5 provider 报废,密钥永久丢失(无任何日志/提示) ``` **关键坏点**:T0→T2 的「DB 有明文」这个唯一存活副本被无条件清空。一旦清空,DB 和 keyring 同时空,**无任何兜底**——`resolve_provider_secret`(secret.rs:30-35)先看 DB、再看 keyring,两源都空就返空串。 ### 1.3 为什么 R-PD-1 比 CR-01 严重 | 项 | CR-01(已修) | R-PD-1(本设计) | |---|---|---| | 触发 | 删 provider 漏清 keyring | 编辑 provider 不改 key | | 后果 | keyring 残留(无消费方,不可复活) | **密钥永久丢失,provider 报废** | | 可逆性 | 残留可后续清,无危害 | **不可逆**——明文唯一副本被覆盖成空 | | 用户感知 | 无 | 静默丢失,下次调用 401/空密钥错才暴露 | CR-01 是「清理时机」问题(残留不可复活),R-PD-1 是「明文副本被毁」问题(密钥丢失)——后者危害量级更高。 --- ## 二、根因 双根因叠加: ### 2.1 根因 A:`INSERT OR REPLACE` 全字段覆盖 `crud.rs:890-900` 的 `AiProviderRepo::insert` 用 `INSERT OR REPLACE INTO ai_providers (...api_key...) VALUES (...)` —— 编辑场景下 id 已存在,REPLACE 整行删除重建,**所有字段**(含 `api_key`)按传入值落库。即使本次只改 `name`,`api_key` 也被强写为 `record.api_key` 的值。 调用方 `ai_save_provider`(commands.rs:334)始终把 `record.api_key` 设为空串,于是无论是否改密钥,DB 明文都被清。 > 注:`update_full`(crud.rs:901-910)走 `UPDATE ... SET api_key = ?` 同样全字段覆盖,问题对称。当前 `ai_save_provider` 走的是 `insert`,但即便切到 `update_full` 也不解决——根因在调用方传的值,不在 SQL 形式。 ### 2.2 根因 B:「空 api_key = 不改」约定二义性 commands.rs:326-334 的约定: - `api_key` 非空 → 写 keyring(新/改密钥) - `api_key` 空 → 不写 keyring,「保留原 keyring 密钥不动」 这套约定隐含假设:**「保留原密钥」就是保留 keyring 里的密钥**。但未迁移态下 keyring 根本没有密钥,真正的密钥副本在 DB 明文里。约定只 cover 了「迁移完成态」(DB 空、keyring 有),完全没考虑「未迁移态」(DB 有明文、keyring 空)。 「空 = 不改」这个三字符约定的语义其实是**「不要动密钥」**,但代码实现成了**「把 DB 明文也清空」**——后者在迁移完成态碰巧无害(DB 本来就空),在未迁移态就是数据丢失。**约定本身没错,错的是实现把「不改」落成了「清空唯一副本」。** ### 2.3 为什么启动迁移没兜住 `migrate_secrets_to_keyring`(secret.rs:50-72)在启动时跑一次: - 成功:DB 明文 → keyring → DB 置空。完成后「未迁移态」消失。 - 失败:`warn` 日志 + `continue`,**DB 明文保留**(设计意图:「下次重试」)。 正是「失败保留明文」这条安全网,制造了「未迁移态」长期存在的可能:keyring 写入失败(权限/锁定/平台差异)→ 明文滞留 DB → 用户进来编辑 → R-PD-1 触发。**这条安全网本意是保住密钥,却被根因 A/B 在编辑路径上反向利用成密钥丢失入口。** --- ## 三、方案对比 修复方向(review 给的指引):**空 `api_key` 时先确认 keyring 有/DB 有再决定清 DB——若 keyring 无且原 DB 非空,先 `set_provider_secret` 补迁再清 DB(即时迁移),保住密钥不丢。** 围绕这个方向,三个候选方案: ### 方案 A:编辑路径即时迁移(推荐) `ai_save_provider` 空密钥分支前,加「保住密钥」前置: 1. 读原 DB 记录的 `api_key`(明文)。 2. 读 keyring 当前值。 3. 决策矩阵: | DB 原值 | keyring 现值 | 动作 | |---|---|---| | 非空 | 非空 | 二者一致?以 keyring 为准,DB 清空(迁移完成态编辑,行为同现状) | | 非空 | 空 | **即时迁移**:`set_provider_secret(DB 原值)` → 成功后 DB 清空;失败 → 报错阻断保存,**DB 明文不动** | | 空 | 非空 | 已迁移态编辑,DB 保持空(现状) | | 空 | 空 | 无密钥 provider(新建未填过 key),DB 保持空(现状) | 伪代码(commands.rs:328-355 改动): ```rust let provider_id = id.clone().unwrap_or_else(new_id); if !api_key.is_empty() { // 显式改密钥:写 keyring(现状不变) if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { return Err(format!("密钥保存到系统钥匙串失败: {}", e)); } } else if let Some(pid) = &id { // 空 key 编辑:保住密钥,防未迁移态丢失 let old = state.ai_providers.get_by_id(pid).await .map_err(|e| e.to_string())?; if let Some(old) = old { if !old.api_key.is_empty() { // DB 有明文 → 检查 keyring 是否已迁 if super::secret::get_provider_secret(pid).is_none() { // keyring 空:即时迁移补密钥(失败则阻断保存,明文不动) if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) { return Err(format!( "检测到密钥尚未迁移至系统钥匙串,本次保存尝试迁移失败: {}。\ 已保留原密钥未改动,请重试或检查系统钥匙串权限后再次保存。", e )); } tracing::info!("[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring", pid); } // keyring 已有/迁移成功:DB 明文将在下方 INSERT OR REPLACE 清空(迁移完成) } } } let api_key = String::new(); // DB 恒空(真实密钥在 keyring) let record = AiProviderRecord { /* ... */ }; ``` **优点**: - 保住密钥不丢(核心目标达成)。 - 顺带把「未迁移态」在编辑路径收敛到「迁移完成态」——用户每编辑一次,未迁移的 provider 自动补迁。 - 调用方局部改动,不动 crud.rs / 不改 IPC 契约 / 不改前端。 - 失败兜底明确:迁移失败直接 `Err` 阻断保存,**不会比现状更糟**(现状是静默丢失,这里至少明确报错 + 不动 DB)。 **缺点**: - 即时迁移失败时阻断保存——用户改个 name 也保存不了。但这是**正确行为**:保存就意味着要清 DB 明文,密钥没保住之前清掉就是丢失,宁可阻断也不丢。 - 多一次 DB 读(`get_by_id`)——可接受(编辑本就低频,且 `ai_save_provider` 已读两次 `get_by_id` 取 `created_at`/`is_default`,再加一次读明文合理)。 ### 方案 B:保留 DB 明文直到 keyring 确认成功 `ai_save_provider` 空密钥分支下,**不无条件清 DB**:若 keyring 无值,则 `record.api_key` 保留原 DB 明文,INSERT OR REPLACE 落库的还是明文;待启动迁移或下次显式改密钥时再清。 伪代码: ```rust let api_key_for_db = if api_key.is_empty() { // 编辑不改 key:若 keyring 无值,保留 DB 明文不动 let keyring_val = super::secret::get_provider_secret(&provider_id); match (keyring_val, id.as_ref().and_then(|pid| /* 读旧 DB 明文 */)) { (Some(_), _) => String::new(), // keyring 有 → DB 可空 (None, Some(plaintext)) => plaintext, // keyring 无 → 保留 DB 明文 (None, None) => String::new(), // 都无 → 新建无 key } } else { // 显式改 key:写 keyring,DB 空 /* set_provider_secret ... */ String::new() }; let record = AiProviderRecord { api_key: api_key_for_db, /* ... */ }; ``` **优点**:保住明文,不依赖即时迁移成功。 **缺点**: - **DB 明文长期滞留**:与 FR-S1「DB api_key 列恒空」目标矛盾,恶化 R-PD-4(迁移失败明文滞留 SQLite 文件未加密)。 - 把「编辑不改 key」从「收敛到迁移完成态」变成「维持未迁移态」,方向反了——本应借编辑机会收敛,方案 B 反而固化未迁移态。 - 决策矩阵更绕(要协调「写 keyring 失败时回退 DB 明文」),引入新的不一致窗口(keyring 写一半失败、DB 仍明文、下次又来一遍)。 ### 方案 C:显式迁移标志位 给 `AiProviderRecord` 加 `secret_migrated: bool` 列(或用 `config` JSON 存),空密钥分支下: - 标志位 true → 已迁移,DB 清空安全。 - 标志位 false → 未迁移,DB 明文必须保留(或即时迁移)。 **优点**:状态显式可观测(不靠「DB 空 vs keyring 有」反推),排查友好。 **缺点**: - **schema 演进成本**:加列要迁移历史库(ALTER TABLE / 默认值 / 向后兼容老客户端读不懂新列)。 - 三个真值源(标志位、DB 明文、keyring)比两个(DB 明文、keyring)更难保持一致——标志位忘更新又成新坑。 - 收益与复杂度不匹配:方案 A 用「keyring 有/DB 有」二元判定已经足够,标志位是过度设计。 - 与项目「务实最小改动」原则相悖。 ### 方案取舍 | 维度 | A 即时迁移 | B 保留明文 | C 标志位 | |---|---|---|---| | 密钥不丢 | ✅ | ✅ | ✅(靠 A/B 实现) | | 收敛未迁移态 | ✅ 编辑即迁移 | ❌ 维持未迁移 | 取决于实现 | | 改动面 | 局部(commands.rs) | 局部(commands.rs) | 大(schema + crud + 模型 + 迁移) | | 与 FR-S1/R-PD-4 一致 | ✅ | ❌ 恶化明文滞留 | 中性 | | 复杂度 | 低 | 中 | 高 | **推荐方案 A**:最小局部改动达成核心目标(密钥不丢),顺带收敛未迁移态,与 FR-S1 方向一致,失败兜底明确不劣化现状。 --- ## 四、推荐方案 A:改动清单 ### 4.1 改动文件 | 文件 | 改动 | 行号(截至 2026-06-15) | |---|---|---| | `src-tauri/src/commands/ai/commands.rs` | `ai_save_provider` 空密钥分支前加「保住密钥」前置(即时迁移) | 328-334(在 `let provider_id = ...` 与 `let api_key = String::new()` 之间插入) | **不改动**: - `crates/df-storage/src/crud.rs` —— `INSERT OR REPLACE` 全字段覆盖是 storage 层中性能力,根因在调用方传值;改 SQL 反而把「保留密钥」语义下推到 storage(不该 storage 关心密钥迁移)。 - `src-tauri/src/commands/ai/secret.rs` —— `set/get_provider_secret` 已具备所需能力,复用即可,无需新方法。 - IPC 签名 / 前端 / DB schema —— 全部不动。 ### 4.2 改动伪代码(完整版) `commands.rs:328` 处(原代码): ```rust let provider_id = id.clone().unwrap_or_else(new_id); if !api_key.is_empty() { if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { return Err(format!("密钥保存到系统钥匙串失败: {}", e)); } } let api_key = String::new(); // DB 恒空(真实密钥在 keyring) ``` 改为: ```rust let provider_id = id.clone().unwrap_or_else(new_id); if !api_key.is_empty() { // 显式改/填密钥 → 写 keyring(现状不变) if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { return Err(format!("密钥保存到系统钥匙串失败: {}", e)); } } else if let Some(pid) = &id { // 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1) let old = state.ai_providers.get_by_id(pid).await .map_err(|e| e.to_string())?; if let Some(old) = old { if !old.api_key.is_empty() && super::secret::get_provider_secret(pid).is_none() { // DB 有明文 且 keyring 无 → 即时迁移补密钥 if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) { return Err(format!( "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", e )); } tracing::info!( "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)", pid ); } // else: keyring 已有 / DB 已空 → INSERT OR REPLACE 清空 DB 明文安全 } } let api_key = String::new(); // DB 恒空(真实密钥在 keyring) ``` ### 4.3 决策点 | 决策 | 取值 | 原因 | |---|---|---| | 即时迁移失败时 | `Err` 阻断保存,**DB 明文不动** | 保存即清 DB 明文,密钥没保住前清掉就是丢失;阻断 + 明确报错优于静默丢失 | | 判定密钥源 | keyring 有 → 安全清;DB 有 + keyring 无 → 即时迁移;都无 → 新建无 key | 三状态全覆盖,无遗漏分支 | | 迁移后是否额外校验 keyring 写入 | 不校验(信任 `set_provider_secret` 返回 Ok) | `set_provider_secret` 已是 keyring 写入的真相源,重复读 keyring 验证属过度防御 | | 即时迁移的范围 | 仅编辑路径(`ai_save_provider` 空 key 分支) | 启动迁移 `migrate_secrets_to_keyring` 是批量兜底,编辑路径是单点收敛;两者互补不重叠 | | 是否记日志 | 成功迁移记 `info`,失败走 `Err`(用户可见) | 成功迁移是状态收敛好事值得记;失败用户必须知道 | --- ## 五、风险与兜底 ### 5.1 即时迁移失败时的兜底 即时迁移失败(keyring 权限/锁定/平台问题)→ 函数 `return Err` → **DB 明文保留不变**(INSERT OR REPLACE 未执行)。 - 用户看到:明确错误「即时迁移失败,请检查钥匙串权限后再次保存」。 - 系统状态:与保存前完全一致(DB 明文还在,keyring 仍空,下次启动迁移或下次编辑还会再试)。 - **绝不劣化现状**:现状是静默丢失,本方案最坏是「保存失败 + 明确报错 + 状态不变」。 ### 5.2 边界场景 | 场景 | 行为 | |---|---| | 编辑刚新建(id 不存在/无 old 记录) | `old = None` → 不进迁移分支 → DB 空(新建无 key 正常) | | 编辑已迁移态(DB 空、keyring 有) | `old.api_key.is_empty()` → 不进迁移分支 → DB 保持空(现状) | | 编辑已迁移态但 keyring 被外部清空 | DB 空 + keyring 空 → 不进迁移分支 → DB 保持空 → provider 早已报废(非本设计引入的新问题,属 R-PD-4 范畴) | | 并发两次保存同一 provider | `get_by_id` 各读各的,INSERT OR REPLACE 串行化落库;最坏后写覆盖先写,密钥不丢(两者都迁成功或都报错) | | 用户编辑同时填了新 api_key | 走 `!api_key.is_empty()` 显式分支,覆盖写 keyring(现状不变,不进即时迁移分支) | ### 5.3 不解决的问题(明确边界) - **R-PD-4**(迁移失败明文长期滞留 SQLite 文件未加密):本方案不直接解决——即时迁移只是把「未迁移态」在编辑路径收敛,启动迁移失败仍会留下滞留明文。R-PD-4 走独立方向(补 N 次失败阈值警告),不在本设计范围。 - **provider 被外部清空 keyring 导致已迁移态变废**:本方案不感知外部 keyring 变更(编辑时读 keyring 是即时快照),属 keyring 健康监控范畴,不在本设计。 - **CR-01**(删 provider 漏清 keyring,已修):删除路径已加 `delete_provider_secret` 兜底,与本设计(编辑路径)正交。 --- ## 六、关联 - **全局代码 review 2026-06-15** §🔴 P1 R-PD-1 — 问题来源与本设计指针。 - **CR-260615-01 / CR-01**(已修):`ai_delete_provider` 删 provider 漏清 keyring → 已加 `delete_provider_secret` 兜底(commands.rs:397-399)。本设计是「编辑路径」的对称补丁,与「删除路径」构成密钥生命周期的两端健壮性。 - **R-PD-4**(P2 需设计):keyring 迁移失败明文滞留 SQLite 文件未加密 — 与本设计同源(启动迁移失败制造未迁移态),但治理方向不同(本设计收敛编辑路径,R-PD-4 加滞留告警)。两者互补。 - **FR-S1**(api_key 密钥管理):`secret.rs` 的 keyring 迁移机制(启动迁移 + resolve fallback + set/get/delete)是本设计依赖的基础设施。本方案在编辑路径补一个「即时迁移」单点,与启动批量迁移形成双层兜底。 - **migrate_secrets_to_keyring**(secret.rs:50-72):启动批量迁移,失败保留明文重试——本设计借编辑路径在用户操作时再做一次单点迁移,提升收敛率。 - **resolve_provider_secret**(secret.rs:30-35):DB 优先 fallback keyring 的双源 resolve,是「未迁移态」仍可用的原因;本方案收敛未迁移态后,resolve 路径长期看会稳定走 keyring 分支。 --- ## 七、测试设计 | 用例 | 方法 | 期望 | |---|---|---| | 未迁移态编辑不改 key → 即时迁移成功 | mock:DB 存明文 + keyring 空,调用 `ai_save_provider` 空 key 改 name | 迁移成功,keyring 写入明文,DB `api_key` 清空,函数返回 Ok(id) | | 未迁移态编辑不改 key → 即时迁移失败 | mock:`set_provider_secret` 返回 Err,DB 存明文 | 函数返回 Err(含迁移失败提示),**DB `api_key` 明文保留不变**(核心兜底) | | 已迁移态编辑不改 key | mock:DB 空 + keyring 有,调用空 key 编辑 | 不进迁移分支,DB 保持空,函数返回 Ok | | 新建 provider 无 key | `id=None`,`api_key=""` | 不进迁移分支(无 old 记录),DB 空,返回 Ok | | 显式改 key(非空 api_key) | 任意态,传非空 api_key | 走显式分支写 keyring,不进即时迁移分支(现状不变) | | 已迁移态但 keyring 被外部清 + DB 也空 | mock:DB 空 + keyring 空 | 不进迁移分支,DB 保持空(provider 已废,非本设计引入) | | 即时迁移后 resolve 正常 | 即时迁移成功后调 `resolve_provider_secret` | 返回非空密钥(迁移成功后 keyring 是唯一源) | 测试位置:`src-tauri/src/commands/ai/commands.rs` 的 `#[cfg(test)]` 模块(若现无则新增),mock `set/get_provider_secret`(可通过 trait 抽象 + 测试替身,或抽 secret 操作到可注入句柄)。 --- **相关**: - `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 R-PD-1 — 问题来源 - `src-tauri/src/commands/ai/commands.rs:299-355` — `ai_save_provider` 实施位置 - `src-tauri/src/commands/ai/secret.rs` — keyring 迁移/resolve 基础设施 - `crates/df-storage/src/crud.rs:884-911` — `AiProviderRepo` insert/update_full(不改) - 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)