Skip to main content

wecomx_auth/
file_store.rs

1//! 加密文件凭据存储:AES-256-GCM 加密的 `credentials.enc` + keyring 回退。
2//!
3//! 适合本地开发机;容器 / 服务器部署建议改用
4//! [`MemoryCredentialStore`](crate::credentials::MemoryCredentialStore) 配合
5//! 环境变量 / Secret Manager 注入,或自行实现
6//! [`CredentialStore`](crate::credentials::CredentialStore) 对接 KMS / Vault。
7
8use std::fs;
9use std::path::PathBuf;
10
11use crate::credentials::{CredentialStore, Credentials};
12use crate::crypto;
13use crate::error::AuthError;
14
15/// 加密文件凭据存储。
16///
17/// bot 信息与 Bearer token 共存于目录内单一加密文件 `credentials.enc`
18/// (AES-256-GCM,密钥存 `.encryption_key` 文件 + 系统 keyring 回退);
19/// bot 与 token 均空时不落盘(删除既有文件,避免残留空凭据)。
20#[derive(Debug, Clone)]
21pub struct EncryptedFileCredentialStore {
22    dir: PathBuf,
23    use_keyring: bool,
24}
25
26impl EncryptedFileCredentialStore {
27    /// 在 `dir` 下创建存储(keyring 默认启用)。
28    pub fn new(dir: impl Into<PathBuf>) -> Self {
29        Self {
30            dir: dir.into(),
31            use_keyring: true,
32        }
33    }
34
35    /// 凭据所在目录。
36    pub fn dir(&self) -> &std::path::Path {
37        &self.dir
38    }
39
40    /// keyring 回退是否启用。
41    pub fn keyring_enabled(&self) -> bool {
42        self.use_keyring
43    }
44
45    /// 是否使用系统 keyring 持久化加密密钥(默认启用)。
46    ///
47    /// 容器等无 keyring 环境可关闭;关闭后密钥仅存 `.encryption_key` 文件。
48    #[must_use]
49    pub fn with_keyring(mut self, enabled: bool) -> Self {
50        self.use_keyring = enabled;
51        self
52    }
53
54    /// 加密凭据文件路径(`<dir>/credentials.enc`)。
55    pub fn credentials_path(&self) -> PathBuf {
56        self.dir.join("credentials.enc")
57    }
58
59    /// 旧版独立凭据文件(`bot.enc` / `token.enc`)路径。
60    ///
61    /// 凭据统一存放于 `credentials.enc`;旧文件**不主动清理**——仅由
62    /// [`try_migrate_legacy_credentials`](crate::legacy_migration) 在迁移时读取。
63    pub fn legacy_paths(&self) -> [PathBuf; 2] {
64        [self.dir.join("bot.enc"), self.dir.join("token.enc")]
65    }
66
67    /// 加密密钥文件路径(`<dir>/.encryption_key`)。
68    pub fn encryption_key_path(&self) -> PathBuf {
69        crypto::encryption_key_path(&self.dir)
70    }
71
72    /// 生成新的随机加密密钥。
73    pub fn generate_key(&self) -> [u8; 32] {
74        crypto::generate_random_key()
75    }
76}
77
78impl CredentialStore for EncryptedFileCredentialStore {
79    /// 读取凭据文件。文件缺失或解密失败(密文损坏 / 密钥不符)返回 `None`。
80    #[allow(clippy::disallowed_methods)]
81    fn load(&self) -> Result<Option<Credentials>, AuthError> {
82        let path = self.credentials_path();
83        let data = match fs::read(&path) {
84            Ok(data) => data,
85            // 凭据缺失不算错误:与「未授权」表现一致。
86            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
87            Err(e) => {
88                return Err(AuthError::Storage(format!(
89                    "读取凭据文件 {} 失败: {e}",
90                    path.display()
91                )));
92            }
93        };
94        let creds = crypto::try_decrypt_data(&self.dir, self.use_keyring, &data)
95            .inspect_err(|e| {
96                tracing::warn!(path = %path.display(), error = %e, "failed to decrypt credentials");
97            })
98            .ok();
99        Ok(creds)
100    }
101
102    /// 加密并持久化凭据;bot 与 token 均空时删除凭据文件。
103    fn save(&self, creds: &Credentials) -> Result<(), AuthError> {
104        if creds.bot.is_none() && creds.token.is_none() {
105            return self.clear();
106        }
107        let key = crypto::load_key_from_file(&self.dir)
108            .or_else(|| crypto::load_key_from_keyring(&self.dir).filter(|_| self.use_keyring))
109            .unwrap_or_else(|| {
110                let k = crypto::generate_random_key();
111                tracing::info!("generated a new encryption key");
112                k
113            });
114        crypto::save_key(&self.dir, &key, self.use_keyring)?;
115        let encrypted = crypto::encrypt_data(creds, &key)?;
116        crypto::atomic_write(&self.credentials_path(), &encrypted, 0o600)?;
117        tracing::info!("credentials saved");
118        Ok(())
119    }
120
121    /// 删除凭据文件(不存在时 no-op)。
122    #[allow(clippy::disallowed_methods)]
123    fn clear(&self) -> Result<(), AuthError> {
124        let path = self.credentials_path();
125        if path.exists() {
126            fs::remove_file(&path).map_err(|e| {
127                AuthError::Storage(format!("删除凭据文件 {} 失败: {e}", path.display()))
128            })?;
129            tracing::info!("credentials file removed: {}", path.display());
130        }
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    //! ## 模块摘要:file_store(加密文件凭据存储)
138    //!
139    //! ### 关键接口
140    //! - [EncryptedFileCredentialStore] — `credentials.enc` 加密总账实现 [CredentialStore]
141    //! - [EncryptedFileCredentialStore::with_keyring] — 关闭 keyring(容器 / 测试)
142    //!
143    //! ### 关键分支与异常路径
144    //! - 文件缺失 / 密文损坏 / 密钥不符 → load 返回 None
145    //! - bot 与 token 均空 → save 删除文件而非写入
146    //! - 独立 `bot.enc` / `token.enc` → 永不主动删除,仅迁移读取
147
148    use base64::Engine as _;
149
150    use super::*;
151
152    /// 创建 keyring 关闭的临时存储(隔离用户 keyring)。
153    fn store(dir: &std::path::Path) -> EncryptedFileCredentialStore {
154        EncryptedFileCredentialStore::new(dir).with_keyring(false)
155    }
156
157    fn write_key(dir: &std::path::Path, key: &[u8; 32]) {
158        #[allow(clippy::disallowed_methods)] // 测试写入临时目录。
159        std::fs::write(
160            dir.join(".encryption_key"),
161            base64::prelude::BASE64_STANDARD.encode(key),
162        )
163        .unwrap();
164    }
165
166    fn bot(id: &str) -> crate::bot::BotCredential {
167        crate::bot::BotCredential::new(id.to_string(), "secret".into())
168    }
169
170    /// P0:bot + token 保存后可完整读回
171    #[test]
172    fn save_load_roundtrip() {
173        let dir = tempfile::tempdir().unwrap();
174        let store = store(dir.path());
175        write_key(dir.path(), &store.generate_key());
176
177        let creds = Credentials {
178            bot: Some(bot("bot1")),
179            token: Some("tok-1".into()),
180        };
181        store.save(&creds).unwrap();
182        let loaded = store.load().unwrap().unwrap();
183        assert_eq!(loaded.bot.as_ref().map(|b| b.id.as_str()), Some("bot1"));
184        assert_eq!(loaded.token.as_deref(), Some("tok-1"));
185    }
186
187    /// P0:bot 与 token 均空时删除凭据文件
188    #[test]
189    fn save_empty_creds_deletes_file() {
190        let dir = tempfile::tempdir().unwrap();
191        let store = store(dir.path());
192        write_key(dir.path(), &store.generate_key());
193
194        store.save(&Credentials::default()).unwrap();
195        assert!(!dir.path().join("credentials.enc").exists());
196    }
197
198    /// P0:bot 与 token 可独立更新互不影响
199    #[test]
200    fn bot_and_token_independent() {
201        let dir = tempfile::tempdir().unwrap();
202        let store = store(dir.path());
203        write_key(dir.path(), &store.generate_key());
204
205        let mut c = store.load().unwrap().unwrap_or_default();
206        c.bot = Some(bot("bot2"));
207        store.save(&c).unwrap();
208        let mut c = store.load().unwrap().unwrap_or_default();
209        c.token = Some("tok-2".into());
210        store.save(&c).unwrap();
211        let loaded = store.load().unwrap().unwrap();
212        assert_eq!(loaded.bot.as_ref().map(|b| b.id.as_str()), Some("bot2"));
213        assert_eq!(loaded.token.as_deref(), Some("tok-2"));
214    }
215
216    /// P0:clear 删除已存在的凭据文件;对缺失文件 no-op
217    #[test]
218    fn clear_deletes_file_and_is_noop_when_missing() {
219        let dir = tempfile::tempdir().unwrap();
220        let store = store(dir.path());
221        write_key(dir.path(), &store.generate_key());
222
223        let c = Credentials {
224            bot: Some(bot("bot4")),
225            token: Some("tok-4".into()),
226        };
227        store.save(&c).unwrap();
228        store.clear().unwrap();
229        assert!(!dir.path().join("credentials.enc").exists());
230        store.clear().unwrap();
231    }
232
233    /// P0:无 credentials.enc 时 load 不清除 legacy(迁移失败场景,legacy 是唯一凭据来源)
234    #[test]
235    fn legacy_files_kept_when_no_credentials() {
236        let dir = tempfile::tempdir().unwrap();
237        let store = store(dir.path());
238        write_key(dir.path(), &store.generate_key());
239        #[allow(clippy::disallowed_methods)] // 测试写入临时目录。
240        std::fs::write(dir.path().join("bot.enc"), b"legacy-bot").unwrap();
241        #[allow(clippy::disallowed_methods)] // 测试写入临时目录。
242        std::fs::write(dir.path().join("token.enc"), b"legacy-token").unwrap();
243
244        assert!(store.load().unwrap().is_none());
245        assert!(dir.path().join("bot.enc").exists(), "legacy must be kept");
246        assert!(dir.path().join("token.enc").exists(), "legacy must be kept");
247    }
248
249    /// P0:credentials.enc 已就位时 load/save 也不清理 legacy
250    #[test]
251    fn legacy_files_kept_even_with_credentials() {
252        let dir = tempfile::tempdir().unwrap();
253        let store = store(dir.path());
254        write_key(dir.path(), &store.generate_key());
255
256        let c = Credentials {
257            bot: Some(bot("bot1")),
258            token: Some("tok-1".into()),
259        };
260        store.save(&c).unwrap();
261        // save 后手动放置 legacy(模拟迁移完成后仍残留的场景)。
262        #[allow(clippy::disallowed_methods)] // 测试写入临时目录。
263        std::fs::write(dir.path().join("bot.enc"), b"legacy-bot").unwrap();
264
265        assert!(store.load().unwrap().is_some());
266        assert!(dir.path().join("bot.enc").exists(), "legacy must be kept");
267    }
268
269    /// P1:凭据文件缺失时 load 返回 None
270    #[test]
271    fn load_missing_returns_none() {
272        let dir = tempfile::tempdir().unwrap();
273        let store = store(dir.path());
274        assert!(store.load().unwrap().is_none());
275    }
276
277    /// P1:凭据文件内容损坏时 load 返回 None
278    #[test]
279    fn load_corrupted_returns_none() {
280        let dir = tempfile::tempdir().unwrap();
281        let store = store(dir.path());
282        write_key(dir.path(), &store.generate_key());
283        #[allow(clippy::disallowed_methods)] // 测试写入临时目录。
284        std::fs::write(dir.path().join("credentials.enc"), b"garbage").unwrap();
285        assert!(store.load().unwrap().is_none());
286    }
287
288    /// P1:密钥不符时 load 返回 None(密文无法解密)
289    #[test]
290    fn load_wrong_key_returns_none() {
291        let dir = tempfile::tempdir().unwrap();
292        let store = store(dir.path());
293        write_key(dir.path(), &store.generate_key());
294
295        let c = Credentials {
296            bot: Some(bot("bot1")),
297            token: Some("tok-secret".into()),
298        };
299        store.save(&c).unwrap();
300
301        write_key(dir.path(), &store.generate_key()); // 替换密钥 → 密文无法解密
302        assert!(store.load().unwrap().is_none());
303    }
304}