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 保留
1418use std:: path:: { Path , PathBuf } ;
1519use std:: sync:: Mutex ;
1620
17- /// 凭据存储抽象(测试注入内存桩;平台存储 Windows=DPAPI)。
21+ /// 凭据存储抽象(scope:默认条目 "default" 或 per-provider "provider:<id>";
22+ /// 测试注入内存桩;平台存储 Windows=DPAPI 加密文件)。
1823pub 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" ) ]
5373impl 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 ) ]
153173pub struct MemoryCredentialStore {
154- inner : Mutex < Option < String > > ,
174+ inner : Mutex < std :: collections :: HashMap < String , String > > ,
155175}
156176
157177impl 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}
0 commit comments