diff --git a/Cargo.lock b/Cargo.lock index 35a831d..61eb98a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -465,6 +476,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.45" @@ -848,6 +865,7 @@ version = "0.1.0" dependencies = [ "anyhow", "df-core", + "keyring", "rusqlite", "serde", "serde_json", @@ -2182,10 +2200,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", + "linux-keyutils", "log", + "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", + "zbus 4.4.0", "zeroize", ] @@ -2264,6 +2285,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2418,6 +2449,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -2428,12 +2472,76 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2948,6 +3056,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3056,6 +3173,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3408,6 +3555,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "futures-util", + "generic-array", + "num", + "once_cell", + "rand", + "serde", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3643,6 +3805,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3758,6 +3931,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4127,7 +4306,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows", - "zbus", + "zbus 5.16.0", ] [[package]] @@ -5728,6 +5907,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5751,6 +5940,44 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.16.0" @@ -5781,9 +6008,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -5796,9 +6036,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -5809,7 +6060,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.12.0", ] [[package]] @@ -5898,6 +6149,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.12.0" @@ -5908,8 +6172,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -5922,7 +6199,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.4.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 06bdb00..626ddde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,8 @@ uuid = { version = "1", features = ["v4"] } rusqlite = { version = "0.31", features = ["bundled"] } tracing = "0.1" regex = "1" +# keyring v3 默认不带任何 platform 后端,必须显式启 feature—— +# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空。 +# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。 +# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。 +keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] } diff --git a/crates/df-storage/Cargo.toml b/crates/df-storage/Cargo.toml index 8938140..d6021a1 100644 --- a/crates/df-storage/Cargo.toml +++ b/crates/df-storage/Cargo.toml @@ -11,3 +11,6 @@ anyhow = { workspace = true } tokio = { workspace = true } rusqlite = { workspace = true } tracing = { workspace = true } +# keyring:FR-S1 密钥解析下沉,纯密钥逻辑(get/set/delete/resolve/ensure/migrate)的唯一源。 +# build_provider_for 不下沉(依赖 df-ai 会引循环),留 src-tauri 转发壳。 +keyring = { workspace = true } diff --git a/crates/df-storage/src/lib.rs b/crates/df-storage/src/lib.rs index bc70573..a367fb3 100644 --- a/crates/df-storage/src/lib.rs +++ b/crates/df-storage/src/lib.rs @@ -4,3 +4,4 @@ pub mod crud; pub mod db; pub mod migrations; pub mod models; +pub mod secret; diff --git a/crates/df-storage/src/secret.rs b/crates/df-storage/src/secret.rs new file mode 100644 index 0000000..27eaca0 --- /dev/null +++ b/crates/df-storage/src/secret.rs @@ -0,0 +1,237 @@ +//! 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 创建失败: {}", 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 真实密钥 — keyring 优先,fallback DB.api_key(兼容未迁移老库) +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 写入失败: {}", 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 删除失败: {}", 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, is_default: false, + config: None, created_at: "0".into(), updated_at: "0".into(), + }; + 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, "删除后应读不到"); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 11529ae..6233316 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,7 +36,7 @@ df-project = { path = "../crates/df-project" } futures = "0.3" # write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等) base64 = "0.22" -# keyring v3 默认不带任何 platform 后端,必须显式启 feature—— -# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空(=FR-S1 密钥存了等于没存,根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md) -# Win+mac 启用原生后端;linux(secret-service)按需补 -keyring = { version = "3", features = ["windows-native", "apple-native"] } +# keyring:密钥解析下沉到 df-storage(workspace 统一声明平台 feature), +# src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。 +# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md +keyring = { workspace = true } diff --git a/src-tauri/src/commands/ai/secret.rs b/src-tauri/src/commands/ai/secret.rs index ee66b5e..5733c1c 100644 --- a/src-tauri/src/commands/ai/secret.rs +++ b/src-tauri/src/commands/ai/secret.rs @@ -1,159 +1,25 @@ -//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串 +//! FR-S1 api_key 密钥管理 — **转发壳(方案 B 下沉,2026-06-16)** //! -//! 设计: -//! - 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` 取:keyring 优先,fallback DB(兼容未迁移) -//! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service +//! 纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)已下沉到 +//! `df_storage::secret`(密钥解析唯一源,供 df-nodes AiNode 与本转发壳共用,DRY)。 +//! +//! 本文件保留两项职责: +//! 1. **转发**:`pub use df_storage::secret::*;` —— 保 src-tauri 内调用路径 +//! `crate::commands::ai::secret::*` / `super::secret::*` 不变(12 调用点零改动)。 +//! 2. **`build_provider_for`**:消费点统一入口(resolve→ensure→`df_ai::build_provider` 三步打包)。 +//! 此函数依赖 `df_ai::build_provider`,**不下沉** —— df-storage 不依赖 df-ai, +//! 下沉会引 df-storage→df-ai 反向依赖(循环)。src-tauri 既依赖 df-storage 又依赖 df-ai, +//! 天然适合拼装,故留 app 层。 +//! +//! 下沉前原文件全貌见 git 历史(commit c10adaf 前 src-tauri/src/commands/ai/secret.rs:1-228)。 -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; +// 转发所有下沉的纯密钥函数,保 src-tauri 内调用路径不变。 +// 不再额外 `use df_storage::secret::{...}` 显式导入 —— 会与 glob re-export 冲突 +// (编译器判 private function import)。build_provider_for 内用全限定路径调即可。 +#[allow(unused_imports)] +pub use df_storage::secret::*; -use df_storage::crud::AiProviderRepo; use df_storage::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 创建失败: {}", 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 真实密钥 — keyring 优先,fallback DB.api_key(兼容未迁移老库) -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 写入失败: {}", 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 删除失败: {}", 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)升级告警,提示明文密钥长期滞留 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) -} /// 消费点统一入口:resolve_provider_secret → ensure_resolved_key → build_provider 三步打包。 /// @@ -161,11 +27,13 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result /// 空 key 照发请求吃 401,用户看「key 已保存」反复重试无解)。返 Result 让调用方按场景处理: /// - 主链(agentic loop):空 key 早失败 emit AiError /// - 后台 task(title/knowledge 提炼/embedding):Err 上层降级/记 log +/// +/// **不下沉原因**:依赖 `df_ai::build_provider`,下沉 df-storage 会引 df-storage→df-ai 循环依赖。 pub fn build_provider_for( record: &AiProviderRecord, ) -> Result, String> { - let api_key = resolve_provider_secret(record); - ensure_resolved_key(&record.name, &api_key)?; + let api_key = df_storage::secret::resolve_provider_secret(record); + df_storage::secret::ensure_resolved_key(&record.name, &api_key)?; Ok(df_ai::build_provider( &record.provider_type, &record.base_url, @@ -173,56 +41,3 @@ pub fn build_provider_for( &record.default_model, )) } - -/// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→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 兼容未迁移老库) - use df_storage::models::AiProviderRecord; - 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, is_default: false, - config: None, created_at: "0".into(), updated_at: "0".into(), - }; - assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback"); - } -}