squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
20 KiB
密钥迁移健壮性设计
真相源(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
背景:R-PD-1(全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空
api_key时,无条件把 DBapi_key置空走INSERT OR REPLACE,未迁移态 provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。 状态:📐 设计完成,未实施 | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15
一、问题复现:精确触发条件
1.1 触发链路
前置条件(未迁移态):
- 历史 DB:
ai_providers.api_key列存有明文密钥(FR-S1 之前的老数据)。 - keyring:对应
provider_id无 entry(迁移未成功,或启动迁移被跳过/失败)。 - 即「DB 有明文、keyring 空」的双源不一致态。
操作:
- 用户进入「设置 → 提供商」,点编辑某 provider。
- 仅修改
name/base_url(不重新填api_key)。 - 前端按约定把空
api_key字段传给 IPC(约定:空 = 不改密钥)。 - 后端
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 空密钥分支前,加「保住密钥」前置:
- 读原 DB 记录的
api_key(明文)。 - 读 keyring 当前值。
- 决策矩阵:
| DB 原值 | keyring 现值 | 动作 |
|---|---|---|
| 非空 | 非空 | 二者一致?以 keyring 为准,DB 清空(迁移完成态编辑,行为同现状) |
| 非空 | 空 | 即时迁移:set_provider_secret(DB 原值) → 成功后 DB 清空;失败 → 报错阻断保存,DB 明文不动 |
| 空 | 非空 | 已迁移态编辑,DB 保持空(现状) |
| 空 | 空 | 无密钥 provider(新建未填过 key),DB 保持空(现状) |
伪代码(commands.rs:328-355 改动):
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 落库的还是明文;待启动迁移或下次显式改密钥时再清。
伪代码:
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 处(原代码):
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)
改为:
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—AiProviderRepoinsert/update_full(不改)- 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)