Skip to content

Commit ccd8063

Browse files
committed
feat(ai): 凭据库 scope 化——per-provider DPAPI 密钥隔离(v0.11.6 M1)
1 parent 172f490 commit ccd8063

6 files changed

Lines changed: 81 additions & 40 deletions

File tree

‎app/src-tauri/src/ai_credentials.rs‎

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
//! AI 密钥凭据存储(REQ-138,v0.8.0 M1 使能层)。
1+
//! AI 密钥凭据存储(REQ-138,v0.8.0 M1 使能层;v0.11.6 M1 scope 化)。
22
//!
33
//! @ai-context: 安全红线(AGENTS.md §4):密钥不落 SQLite/明文文件——
44
//! Windows 用 DPAPI CryptProtectData 加密后写入数据目录
55
//! ai_credentials.bin(当前用户作用域,管理员与其他用户不可解;
66
//! CRYPTPROTECT_UI_FORBIDDEN 禁弹窗,服务化场景不卡 UI)。
7+
//! @ai-context: scope 化(v0.11.6 M1):默认条目 scope="default" 保持旧文件名
8+
//! ai_credentials.bin 向后兼容(已存用户凭据不失效);per-provider
9+
//! 隔离用 "provider:<id>" → ai_credentials_<safe_id>.bin,各自独立
10+
//! DPAPI 加密。scope 字符白名单:字母/数字/-/_,其余映射为 '_'。
711
//! @ai-context: keyring crate spike 因本机 TLS 拦截(crates.io 新依赖下载
812
//! 失败)跳过,直接走 v0.8.0 规划裁决的 DPAPI 直写 fallback
913
//! 路径(裁决见 ADR-016);环境变量 SILICONFLOW_API_KEY 保留
@@ -14,11 +18,12 @@
1418
use std::path::{Path, PathBuf};
1519
use std::sync::Mutex;
1620

17-
/// 凭据存储抽象(测试注入内存桩;平台存储 Windows=DPAPI)。
21+
/// 凭据存储抽象(scope:默认条目 "default" 或 per-provider "provider:<id>";
22+
/// 测试注入内存桩;平台存储 Windows=DPAPI 加密文件)。
1823
pub trait CredentialStore: Send + Sync {
19-
fn save_key(&self, api_key: &str) -> Result<(), String>;
20-
fn load_key(&self) -> Result<Option<String>, String>;
21-
fn clear_key(&self) -> Result<(), String>;
24+
fn save_key(&self, scope: &str, api_key: &str) -> Result<(), String>;
25+
fn load_key(&self, scope: &str) -> Result<Option<String>, String>;
26+
fn clear_key(&self, scope: &str) -> Result<(), String>;
2227
}
2328

2429
/// 平台存储构造(Windows=DPAPI 加密文件;非 Windows 内存存储 + 告警——
@@ -49,19 +54,34 @@ pub struct DpapiCredentialStore {
4954
path: PathBuf,
5055
}
5156

57+
#[cfg(target_os = "windows")]
58+
impl DpapiCredentialStore {
59+
/// scope → 凭据文件路径(default=旧文件名兼容;provider:<id> 按 id 隔离)
60+
fn scoped_path(&self, scope: &str) -> PathBuf {
61+
if scope == "default" {
62+
return self.path.clone();
63+
}
64+
let safe: String = scope
65+
.chars()
66+
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
67+
.collect();
68+
self.path.with_file_name(format!("ai_credentials_{}.bin", safe))
69+
}
70+
}
71+
5272
#[cfg(target_os = "windows")]
5373
impl CredentialStore for DpapiCredentialStore {
54-
fn save_key(&self, api_key: &str) -> Result<(), String> {
74+
fn save_key(&self, scope: &str, api_key: &str) -> Result<(), String> {
5575
if api_key.trim().is_empty() {
5676
return Err("密钥不能为空".to_string());
5777
}
5878
let encrypted = dpapi_protect(api_key.as_bytes())?;
59-
std::fs::write(&self.path, encrypted)
79+
std::fs::write(self.scoped_path(scope), encrypted)
6080
.map_err(|e| format!("写入凭据文件失败: {}", e))
6181
}
6282

63-
fn load_key(&self) -> Result<Option<String>, String> {
64-
let raw = match std::fs::read(&self.path) {
83+
fn load_key(&self, scope: &str) -> Result<Option<String>, String> {
84+
let raw = match std::fs::read(self.scoped_path(scope)) {
6585
Ok(b) => b,
6686
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6787
Err(e) => return Err(format!("读取凭据文件失败: {}", e)),
@@ -74,8 +94,8 @@ impl CredentialStore for DpapiCredentialStore {
7494
Ok(Some(key))
7595
}
7696

77-
fn clear_key(&self) -> Result<(), String> {
78-
match std::fs::remove_file(&self.path) {
97+
fn clear_key(&self, scope: &str) -> Result<(), String> {
98+
match std::fs::remove_file(self.scoped_path(scope)) {
7999
Ok(()) => Ok(()),
80100
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
81101
Err(e) => Err(format!("删除凭据文件失败: {}", e)),
@@ -151,24 +171,30 @@ fn dpapi_unprotect(blob: &[u8]) -> Result<Vec<u8>, String> {
151171
#[allow(dead_code)]
152172
#[derive(Default)]
153173
pub struct MemoryCredentialStore {
154-
inner: Mutex<Option<String>>,
174+
inner: Mutex<std::collections::HashMap<String, String>>,
155175
}
156176

157177
impl CredentialStore for MemoryCredentialStore {
158-
fn save_key(&self, api_key: &str) -> Result<(), String> {
178+
fn save_key(&self, scope: &str, api_key: &str) -> Result<(), String> {
159179
if api_key.trim().is_empty() {
160180
return Err("密钥不能为空".to_string());
161181
}
162-
*self.inner.lock().map_err(|_| "凭据锁中毒".to_string())? = Some(api_key.to_string());
182+
self.inner
183+
.lock()
184+
.map_err(|_| "凭据锁中毒".to_string())?
185+
.insert(scope.to_string(), api_key.to_string());
163186
Ok(())
164187
}
165188

166-
fn load_key(&self) -> Result<Option<String>, String> {
167-
Ok(self.inner.lock().map_err(|_| "凭据锁中毒".to_string())?.clone())
189+
fn load_key(&self, scope: &str) -> Result<Option<String>, String> {
190+
Ok(self.inner.lock().map_err(|_| "凭据锁中毒".to_string())?.get(scope).cloned())
168191
}
169192

170-
fn clear_key(&self) -> Result<(), String> {
171-
*self.inner.lock().map_err(|_| "凭据锁中毒".to_string())? = None;
193+
fn clear_key(&self, scope: &str) -> Result<(), String> {
194+
self.inner
195+
.lock()
196+
.map_err(|_| "凭据锁中毒".to_string())?
197+
.remove(scope);
172198
Ok(())
173199
}
174200
}

‎app/src-tauri/src/ai_credentials_tests.rs‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,58 @@ fn store() -> MemoryCredentialStore {
77
MemoryCredentialStore::default()
88
}
99

10+
/// 默认 scope(与 command 层调用一致——旧测试全部走 "default" 条目)。
11+
const DEFAULT_SCOPE: &str = "default";
12+
1013
#[test]
1114
fn roundtrip_save_load() {
1215
let s = store();
13-
s.save_key("sk-test-123").expect("保存成功");
14-
assert_eq!(s.load_key().expect("读取成功").as_deref(), Some("sk-test-123"));
16+
s.save_key(DEFAULT_SCOPE, "sk-test-123").expect("保存成功");
17+
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功").as_deref(), Some("sk-test-123"));
1518
}
1619

1720
#[test]
1821
fn load_empty_is_none() {
1922
// 未保存 → None(与 DPAPI 文件不存在语义一致)
2023
let s = store();
21-
assert_eq!(s.load_key().expect("读取成功"), None);
24+
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None);
2225
}
2326

2427
#[test]
2528
fn clear_removes_key() {
2629
let s = store();
27-
s.save_key("sk-abc").expect("保存成功");
28-
s.clear_key().expect("清除成功");
29-
assert_eq!(s.load_key().expect("读取成功"), None);
30+
s.save_key(DEFAULT_SCOPE, "sk-abc").expect("保存成功");
31+
s.clear_key(DEFAULT_SCOPE).expect("清除成功");
32+
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None);
3033
// 幂等:重复清除不报错(文件不存在视为已清除)
31-
s.clear_key().expect("重复清除成功");
34+
s.clear_key(DEFAULT_SCOPE).expect("重复清除成功");
3235
}
3336

3437
#[test]
3538
fn empty_key_rejected() {
3639
// 空/空白密钥拒绝保存(防御:不写坏凭据)
3740
let s = store();
38-
assert!(s.save_key("").is_err());
39-
assert!(s.save_key(" ").is_err());
40-
assert_eq!(s.load_key().expect("读取成功"), None);
41+
assert!(s.save_key(DEFAULT_SCOPE, "").is_err());
42+
assert!(s.save_key(DEFAULT_SCOPE, " ").is_err());
43+
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None);
4144
}
4245

4346
#[test]
4447
fn save_overwrites_previous() {
4548
let s = store();
46-
s.save_key("sk-old").expect("保存成功");
47-
s.save_key("sk-new").expect("覆盖保存成功");
48-
assert_eq!(s.load_key().expect("读取成功").as_deref(), Some("sk-new"));
49+
s.save_key(DEFAULT_SCOPE, "sk-old").expect("保存成功");
50+
s.save_key(DEFAULT_SCOPE, "sk-new").expect("覆盖保存成功");
51+
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功").as_deref(), Some("sk-new"));
52+
}
53+
54+
#[test]
55+
fn scoped_store_isolates_keys_by_scope() {
56+
let store = crate::ai_credentials::MemoryCredentialStore::default();
57+
store.save_key("default", "sk-a").unwrap();
58+
store.save_key("provider:p1", "sk-b").unwrap();
59+
assert_eq!(store.load_key("default").unwrap().as_deref(), Some("sk-a"));
60+
assert_eq!(store.load_key("provider:p1").unwrap().as_deref(), Some("sk-b"));
61+
store.clear_key("provider:p1").unwrap();
62+
assert_eq!(store.load_key("provider:p1").unwrap(), None);
63+
assert_eq!(store.load_key("default").unwrap().as_deref(), Some("sk-a"));
4964
}

‎app/src-tauri/src/ai_refine_task.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ fn run_refine_task_inner(
191191
.map_err(|e| AiTaskFailure::Other(e.to_string()))?
192192
.clone();
193193
let env_key = std::env::var("SILICONFLOW_API_KEY").ok().filter(|k| !k.is_empty());
194-
let stored_key = st.ai_credentials.load_key().ok().flatten();
194+
let stored_key = st.ai_credentials.load_key("default").ok().flatten();
195195
// 密钥来源诊断(脱敏:只打长度+前 6 字符;真机 unauthorized 排查 2026-08-21)
196196
eprintln!(
197197
"[refine-task] task={} key: env={} stored={}",

‎app/src-tauri/src/commands_ai_enrich.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ pub async fn ai_enrich_start(
8585
let mock = std::env::var(MOCK_ENV).map(|v| v == "1").unwrap_or(false);
8686
if !mock {
8787
let env_key = std::env::var("SILICONFLOW_API_KEY").ok().filter(|k| !k.is_empty());
88-
let stored = st.ai_credentials.load_key()?;
88+
let stored = st.ai_credentials.load_key("default")?;
8989
if env_key.is_none() && stored.is_none() {
9090
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
9191
}
@@ -274,7 +274,7 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
274274
None => String::new(),
275275
};
276276
let settings = st.ai_settings.lock().map_err(|e| AiTaskFailure::Other(e.to_string()))?.clone();
277-
let client = AiClient::from_settings(&settings, st.ai_credentials.load_key().ok().flatten());
277+
let client = AiClient::from_settings(&settings, st.ai_credentials.load_key("default").ok().flatten());
278278
let adapter = AiNoteEnrichAdapter::new(client.clone());
279279
let mock_adapter = AiMockAdapter;
280280
// 切片(长笔记按章节切——REQ-145 基建复用;锚点跨片=全局章节标题)

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ pub async fn ai_refine_start(
134134
// ② 密钥解析(env > 凭据库);非 mock 且无密钥 → 明确错误(不创建任务)
135135
if !mock {
136136
let env_key = std::env::var("SILICONFLOW_API_KEY").ok().filter(|k| !k.is_empty());
137-
let stored = st.ai_credentials.load_key()?;
137+
let stored = st.ai_credentials.load_key("default")?;
138138
if env_key.is_none() && stored.is_none() {
139139
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
140140
}
@@ -493,7 +493,7 @@ pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Re
493493
let api_key = std::env::var("SILICONFLOW_API_KEY")
494494
.ok()
495495
.filter(|k| !k.is_empty())
496-
.or(st.ai_credentials.load_key().ok().flatten())
496+
.or(st.ai_credentials.load_key("default").ok().flatten())
497497
.unwrap_or_default();
498498
if api_key.is_empty() {
499499
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());

‎app/src-tauri/src/commands_ai_settings.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub struct BalanceView {
4848
pub fn ai_get_settings(state: State<'_, AppState>) -> Result<AiSettingsView, String> {
4949
let s = snapshot_settings(&state)?;
5050
let env_key = env_api_key();
51-
let stored = state.ai_credentials.load_key()?;
51+
let stored = state.ai_credentials.load_key("default")?;
5252
let (has_key, key_source) = if env_key.is_some() {
5353
(true, "env".to_string())
5454
} else if stored.is_some() {
@@ -78,13 +78,13 @@ pub fn ai_save_key(state: State<'_, AppState>, api_key: String) -> Result<(), St
7878
if key.chars().count() > API_KEY_MAX_CHARS {
7979
return Err(format!("密钥超长(上限 {} 字符)", API_KEY_MAX_CHARS));
8080
}
81-
state.ai_credentials.save_key(&key)
81+
state.ai_credentials.save_key("default", &key)
8282
}
8383

8484
/// 清除凭据库密钥(文件不存在视为已清除——幂等)。
8585
#[tauri::command]
8686
pub fn ai_clear_key(state: State<'_, AppState>) -> Result<(), String> {
87-
state.ai_credentials.clear_key()
87+
state.ai_credentials.clear_key("default")
8888
}
8989

9090
/// 更新 AI 设置(白名单校验后持久化;command 层锁内 read-modify-write)。
@@ -216,7 +216,7 @@ fn snapshot_settings(state: &AppState) -> Result<AiSettings, String> {
216216
fn balance_adapter(state: &AppState) -> Result<AiBalanceAdapter, String> {
217217
let s = snapshot_settings(state)?;
218218
let api_key = env_api_key()
219-
.or(state.ai_credentials.load_key()?)
219+
.or(state.ai_credentials.load_key("default")?)
220220
.unwrap_or_default();
221221
let cfg = crate::ai_client::AiClient::from_settings(&s, Some(api_key)).config;
222222
Ok(AiBalanceAdapter {

0 commit comments

Comments
 (0)