//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。 //! //! **下沉层(方案 B,2026-06-16)**:原位于 `src-tauri/src/commands/ai/secret.rs`, //! 下沉纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)到 df-storage, //! 供 df-nodes AiNode 与 src-tauri 转发壳共用(密钥解析唯一源,DRY)。 //! //! **不下沉项**:`build_provider_for`(依赖 `df_ai::build_provider`)——df-storage 不依赖 //! df-ai,下沉会引 df-storage→df-ai 反向依赖。`build_provider_for` 留 src-tauri 转发壳, //! 内部调本模块 `resolve_provider_secret` + `ensure_resolved_key` + `df_ai::build_provider`。 //! //! 设计: //! - keyring entry: service=`devflow-ai-provider`, username=provider_id //! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring //! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空(失败保留明文下次重试) //! - 消费点(build_provider)经 `resolve_provider_secret` 取:DB 优先,fallback keyring(兼容未迁移) //! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service use std::collections::HashMap; use std::fs; use std::path::PathBuf; use crate::crud::AiProviderRepo; use crate::models::AiProviderRecord; use keyring::Entry; const KEYRING_SERVICE: &str = "devflow-ai-provider"; /// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → 升级为 warn 提示明文密钥长期滞留风险。 /// 跨启动持久化(sidecar 文件),计数仅用于告警,不影响兼容时序(不强制迁移、不删明文)。 const MIGRATION_FAIL_THRESHOLD: u32 = 3; /// 迁移失败计数 sidecar 文件(/.devflow-keyring-failcount):逐行 `provider_id=count`。 /// cwd 未必是稳定路径,但 R-PD-4 目标仅是「检测到反复失败/滞留时告警」,误读为 0 即按未达阈值处理,无副作用。 fn failcount_path() -> PathBuf { std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(".devflow-keyring-failcount") } /// 读取全部失败计数(id → count)。文件缺失/损坏 → 空 map(按未达阈值处理)。 fn read_failcounts() -> HashMap { let mut map = HashMap::new(); if let Ok(text) = fs::read_to_string(failcount_path()) { for line in text.lines() { let mut parts = line.splitn(2, '='); let id = parts.next().unwrap_or("").trim(); let cnt = parts.next().and_then(|s| s.trim().parse::().ok()); if !id.is_empty() { if let Some(c) = cnt { map.insert(id.to_string(), c); } } } } map } /// 持久化全部失败计数。写入失败仅 log,不阻断迁移主流程。 fn write_failcounts(map: &HashMap) { let mut text = String::new(); let mut entries: Vec<_> = map.iter().collect(); entries.sort_by(|a, b| a.0.cmp(b.0)); // 稳定顺序,减少无谓 diff for (id, cnt) in entries { text.push_str(id); text.push('='); text.push_str(&cnt.to_string()); text.push('\n'); } if let Err(e) = fs::write(failcount_path(), text) { tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e); } } /// 记录一次迁移失败并返回累计失败次数。持久化失败也不影响返回值(仍递增内存计数用于本次告警)。 fn record_migration_fail(id: &str) -> u32 { let mut map = read_failcounts(); let next = map.get(id).copied().unwrap_or(0).saturating_add(1); map.insert(id.to_string(), next); write_failcounts(&map); next } /// 清零某 provider 的失败计数(迁移成功后调用,避免历史失败在后续再触发误告警)。 fn clear_migration_failcount(id: &str) { let mut map = read_failcounts(); if map.remove(id).is_some() { write_failcounts(&map); } } fn entry_for(id: &str) -> anyhow::Result { Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败(provider={}): {}", id, e)) } /// 读取 provider 密钥(优先 keyring;无则 None) pub fn get_provider_secret(id: &str) -> Option { let entry = entry_for(id).ok()?; match entry.get_password() { Ok(s) if !s.is_empty() => Some(s), _ => None, } } /// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库) pub fn resolve_provider_secret(record: &AiProviderRecord) -> String { if !record.api_key.is_empty() { return record.api_key.clone(); } get_provider_secret(&record.id).unwrap_or_default() } /// 写入密钥到 keyring(覆盖) pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> { let entry = entry_for(id)?; entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e)) } /// 删除 keyring 密钥(provider 删除时清理) pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> { let entry = entry_for(id)?; entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e)) } /// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断) pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result { let providers = repo.list_all().await?; let mut migrated = 0; for mut p in providers { if p.api_key.is_empty() { continue; // 已迁移或无密钥 } if let Err(e) = set_provider_secret(&p.id, &p.api_key) { // 累计失败次数:达阈值(默认 3)升级告警,提示明文 api_key 长期滞留 SQLite(无加密)风险。 // 计数仅告警用,不改兼容时序——仍保留明文下次重试,不强制迁移、不删明文。 let n = record_migration_fail(&p.id); if n >= MIGRATION_FAIL_THRESHOLD { tracing::warn!( "[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\ 建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\ 2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入", p.id, n ); } else { tracing::warn!( "[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}", p.id, n, MIGRATION_FAIL_THRESHOLD, e ); } continue; } let pid = p.id.clone(); p.api_key.clear(); if let Err(e) = repo.insert(p).await { tracing::warn!("[FR-S1] 迁移后清空 DB api_key 失败 {}: {}", pid, e); } // 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计) clear_migration_failcount(&pid); migrated += 1; } if migrated > 0 { tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated); } Ok(migrated) } /// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→Ok。 /// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。 pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<(), String> { if resolved.trim().is_empty() { Err(format!( "未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存", provider_name )) } else { Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn ensure_resolved_key_rejects_empty() { assert!(ensure_resolved_key("GLM", "").is_err()); } #[test] fn ensure_resolved_key_rejects_whitespace() { // 纯空白也视为无密钥(防粘贴时只有空格) assert!(ensure_resolved_key("GLM", " ").is_err()); } #[test] fn ensure_resolved_key_accepts_nonempty() { assert!(ensure_resolved_key("GLM", "sk-abc").is_ok()); } #[test] fn ensure_resolved_key_error_mentions_provider_name() { let err = ensure_resolved_key("我的提供商", "").unwrap_err(); assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位"); } #[test] fn resolve_prefers_db_when_non_empty() { // DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库) // 纯逻辑路径,不碰 OS keyring,CI 任意 OS 安全。 let rec = AiProviderRecord { id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(), api_key: "sk-db-fallback".into(), base_url: "https://x".into(), default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false, config: None, created_at: "0".into(), updated_at: "0".into(), enabled: true, weight: 50, }; assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback"); } /// keyring 相关单测(cfg-gate):避 CI OS keyring 副作用(无后端/无 GUI 会话报错)。 /// 仅在「桌面 OS + 本地手动」跑(Win/macOS/Linux 桌面环境)。 #[cfg(any(target_os = "windows", target_os = "macos"))] #[test] fn set_get_delete_roundtrip_on_os_keyring() { use std::time::{SystemTime, UNIX_EPOCH}; // 用纳秒戳造唯一 id,避与真实 provider 冲突 + 测后清理。 let id = format!( "df-test-{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() ); // 清理历史残留(上次测试崩溃留下) let _ = delete_provider_secret(&id); assert_eq!(get_provider_secret(&id), None, "清理后应读不到"); assert!(set_provider_secret(&id, "sk-roundtrip").is_ok()); assert_eq!(get_provider_secret(&id).as_deref(), Some("sk-roundtrip")); assert!(delete_provider_secret(&id).is_ok()); assert_eq!(get_provider_secret(&id), None, "删除后应读不到"); } }