Files
DevFlow/docs/02-架构设计/专项设计/密钥迁移健壮性-2026-06-15.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

20 KiB
Raw Blame History

密钥迁移健壮性设计

真相源(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。

背景R-PD-1全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 api_key 时,无条件把 DB api_key 置空走 INSERT OR REPLACE未迁移态 provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。 状态:📐 设计完成,未实施 | 创建2026-06-15 | 来源:全局代码 review 2026-06-15


一、问题复现:精确触发条件

1.1 触发链路

前置条件(未迁移态

  • 历史 DBai_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_secretsecret.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 根因 AINSERT OR REPLACE 全字段覆盖

crud.rs:890-900AiProviderRepo::insertINSERT OR REPLACE INTO ai_providers (...api_key...) VALUES (...) —— 编辑场景下 id 已存在REPLACE 整行删除重建,所有字段(含 api_key)按传入值落库。即使本次只改 nameapi_key 也被强写为 record.api_key 的值。

调用方 ai_save_providercommands.rs:334始终把 record.api_key 设为空串于是无论是否改密钥DB 明文都被清。

注:update_fullcrud.rs:901-910UPDATE ... 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_keyringsecret.rs:50-72在启动时跑一次

  • 成功DB 明文 → keyring → DB 置空。完成后「未迁移态」消失。
  • 失败:warn 日志 + continueDB 明文保留(设计意图:「下次重试」)。

正是「失败保留明文」这条安全网制造了「未迁移态」长期存在的可能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新建未填过 keyDB 保持空(现状)

伪代码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_idcreated_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写 keyringDB 空
    /* 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显式迁移标志位

AiProviderRecordsecret_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-334let 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 {} 密钥至 keyringR-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 ErrDB 明文保留不变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-4P2 需设计keyring 迁移失败明文滞留 SQLite 文件未加密 — 与本设计同源启动迁移失败制造未迁移态但治理方向不同本设计收敛编辑路径R-PD-4 加滞留告警)。两者互补。
  • FR-S1api_key 密钥管理):secret.rs 的 keyring 迁移机制(启动迁移 + resolve fallback + set/get/delete是本设计依赖的基础设施。本方案在编辑路径补一个「即时迁移」单点与启动批量迁移形成双层兜底。
  • migrate_secrets_to_keyringsecret.rs:50-72启动批量迁移失败保留明文重试——本设计借编辑路径在用户操作时再做一次单点迁移提升收敛率。
  • resolve_provider_secretsecret.rs:30-35DB 优先 fallback keyring 的双源 resolve是「未迁移态」仍可用的原因本方案收敛未迁移态后resolve 路径长期看会稳定走 keyring 分支。

七、测试设计

用例 方法 期望
未迁移态编辑不改 key → 即时迁移成功 mockDB 存明文 + keyring 空,调用 ai_save_provider 空 key 改 name 迁移成功keyring 写入明文DB api_key 清空,函数返回 Ok(id)
未迁移态编辑不改 key → 即时迁移失败 mockset_provider_secret 返回 ErrDB 存明文 函数返回 Err含迁移失败提示DB api_key 明文保留不变(核心兜底)
已迁移态编辑不改 key mockDB 空 + keyring 有,调用空 key 编辑 不进迁移分支DB 保持空,函数返回 Ok
新建 provider 无 key id=Noneapi_key="" 不进迁移分支(无 old 记录DB 空,返回 Ok
显式改 key非空 api_key 任意态,传非空 api_key 走显式分支写 keyring不进即时迁移分支现状不变
已迁移态但 keyring 被外部清 + DB 也空 mockDB 空 + 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-355ai_save_provider 实施位置
  • src-tauri/src/commands/ai/secret.rs — keyring 迁移/resolve 基础设施
  • crates/df-storage/src/crud.rs:884-911AiProviderRepo insert/update_full不改
  • 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)