futu_auth/
store.rs

1//! KeyStore: keys.json 加载 + 热替换 + 明文验证
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use arc_swap::ArcSwap;
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10
11use crate::key::KeyRecord;
12
13#[derive(Debug, thiserror::Error)]
14pub enum KeyStoreError {
15    #[error("read {path:?}: {source}")]
16    Read {
17        path: PathBuf,
18        source: std::io::Error,
19    },
20    #[error("parse {path:?}: {source}")]
21    Parse {
22        path: PathBuf,
23        source: serde_json::Error,
24    },
25    #[error("write {path:?}: {source}")]
26    Write {
27        path: PathBuf,
28        source: std::io::Error,
29    },
30    #[error("serialize: {0}")]
31    Serialize(#[from] serde_json::Error),
32    #[error("unsupported keys.json version {0} (supported: 1)")]
33    UnsupportedVersion(u32),
34    #[error("duplicate key id {0:?}")]
35    DuplicateId(String),
36}
37
38/// keys.json 顶层文件结构
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct KeysFile {
41    pub version: u32,
42    pub keys: Vec<KeyRecord>,
43}
44
45const CURRENT_VERSION: u32 = 1;
46
47/// KeyStore:热可替换的 keys 集合
48#[derive(Debug)]
49pub struct KeyStore {
50    path: Option<PathBuf>,
51    current: ArcSwap<KeysFile>,
52}
53
54impl KeyStore {
55    /// 空 store(没有 keys 文件时)
56    pub fn empty() -> Self {
57        Self {
58            path: None,
59            current: ArcSwap::from_pointee(KeysFile {
60                version: CURRENT_VERSION,
61                keys: vec![],
62            }),
63        }
64    }
65
66    /// 从文件加载
67    pub fn load(path: impl Into<PathBuf>) -> Result<Self, KeyStoreError> {
68        let path = path.into();
69        let file = Self::load_file(&path)?;
70        Ok(Self {
71            path: Some(path),
72            current: ArcSwap::from_pointee(file),
73        })
74    }
75
76    fn load_file(path: &Path) -> Result<KeysFile, KeyStoreError> {
77        let text = fs::read_to_string(path).map_err(|source| KeyStoreError::Read {
78            path: path.to_path_buf(),
79            source,
80        })?;
81        let file: KeysFile =
82            serde_json::from_str(&text).map_err(|source| KeyStoreError::Parse {
83                path: path.to_path_buf(),
84                source,
85            })?;
86        if file.version != CURRENT_VERSION {
87            return Err(KeyStoreError::UnsupportedVersion(file.version));
88        }
89        // 检查重复 id
90        let mut seen = std::collections::HashSet::new();
91        for k in &file.keys {
92            if !seen.insert(k.id.clone()) {
93                return Err(KeyStoreError::DuplicateId(k.id.clone()));
94            }
95        }
96        Ok(file)
97    }
98
99    /// SIGHUP 热重载:用同一路径重新读文件
100    pub fn reload(&self) -> Result<(), KeyStoreError> {
101        let Some(path) = &self.path else {
102            return Ok(());
103        };
104        let file = Self::load_file(path)?;
105        self.current.store(Arc::new(file));
106        Ok(())
107    }
108
109    /// 明文校验:遍历所有未过期 key,匹配则返回 KeyRecord 快照
110    ///
111    /// 如果 key 设置了 `allowed_machines` 且本机不在白名单,会打 warn 日志并视为未匹配。
112    /// 这样做法的代价:攻击者可以通过"能不能过"侧信道区分 key 是否存在 — 我们接受,
113    /// 因为 plaintext 空间是 256 bit 随机 hex,侧信道没意义。
114    pub fn verify(&self, plaintext: &str) -> Option<Arc<KeyRecord>> {
115        let snap = self.current.load_full();
116        let now = Utc::now();
117        for k in snap.keys.iter() {
118            if k.is_expired(now) {
119                continue;
120            }
121            if k.matches(plaintext) {
122                if let Err(e) = k.check_machine() {
123                    tracing::warn!(
124                        key_id = %k.id,
125                        error = %e,
126                        "api key matched but machine binding failed; rejecting"
127                    );
128                    return None;
129                }
130                return Some(Arc::new(k.clone()));
131            }
132        }
133        None
134    }
135
136    /// 是否已加载 keys 文件(非 empty)
137    pub fn is_configured(&self) -> bool {
138        self.path.is_some()
139    }
140
141    pub fn path(&self) -> Option<&Path> {
142        self.path.as_deref()
143    }
144
145    pub fn len(&self) -> usize {
146        self.current.load().keys.len()
147    }
148
149    pub fn is_empty(&self) -> bool {
150        self.len() == 0
151    }
152
153    /// 按 id 查询当前快照中的 key(**不做 expiry / machine 校验**,调用方自己做)
154    ///
155    /// 典型用法:MCP 在启动时 `verify(plaintext)` 拿到 id,后续每个请求用
156    /// `get_by_id` 取最新记录,这样 SIGHUP 重载 keys.json 后 scope / 限额 /
157    /// expires_at 的变更能立刻生效(不用重启进程)。
158    ///
159    /// 返回 None 表示 id 在当前文件里不存在(被 remove_key 删掉了),调用方应
160    /// 视为"key 已吊销"直接拒绝。
161    pub fn get_by_id(&self, id: &str) -> Option<Arc<KeyRecord>> {
162        let snap = self.current.load_full();
163        snap.keys
164            .iter()
165            .find(|k| k.id == id)
166            .map(|k| Arc::new(k.clone()))
167    }
168
169    /// 导出当前所有 keys 的 id(用于调试 / 审计)
170    pub fn ids(&self) -> Vec<String> {
171        self.current
172            .load()
173            .keys
174            .iter()
175            .map(|k| k.id.clone())
176            .collect()
177    }
178}
179
180/// 追加一条新 key 到 keys.json(atomic rename)
181pub fn append_key(path: &Path, new_record: KeyRecord) -> Result<(), KeyStoreError> {
182    let mut file = match fs::metadata(path) {
183        Ok(_) => KeyStore::load_file(path)?,
184        Err(_) => KeysFile {
185            version: CURRENT_VERSION,
186            keys: vec![],
187        },
188    };
189    if file.keys.iter().any(|k| k.id == new_record.id) {
190        return Err(KeyStoreError::DuplicateId(new_record.id));
191    }
192    file.keys.push(new_record);
193    write_atomic(path, &file)
194}
195
196/// 读取 keys.json 并返回所有记录快照(展示用;不暴露 hash 以外的敏感位)
197pub fn list_keys(path: &Path) -> Result<Vec<KeyRecord>, KeyStoreError> {
198    let file = KeyStore::load_file(path)?;
199    Ok(file.keys)
200}
201
202/// 按 id 编辑一条 key(atomic rename);闭包返回 `false` 代表未改动 → 跳过落盘
203///
204/// 适用于就地修改 `allowed_machines` / `expires_at` / `note` 等配置,
205/// 而不想走 "revoke + regen" 流程(否则 plaintext 会换)。
206pub fn update_key<F>(path: &Path, id: &str, mutate: F) -> Result<bool, KeyStoreError>
207where
208    F: FnOnce(&mut KeyRecord) -> bool,
209{
210    let mut file = KeyStore::load_file(path)?;
211    let Some(rec) = file.keys.iter_mut().find(|k| k.id == id) else {
212        return Ok(false);
213    };
214    let changed = mutate(rec);
215    if changed {
216        write_atomic(path, &file)?;
217    }
218    Ok(changed)
219}
220
221/// 按 id 删除一条 key(atomic rename);返回是否真的删掉了一条
222pub fn remove_key(path: &Path, id: &str) -> Result<bool, KeyStoreError> {
223    let mut file = KeyStore::load_file(path)?;
224    let before = file.keys.len();
225    file.keys.retain(|k| k.id != id);
226    let removed = before != file.keys.len();
227    if removed {
228        write_atomic(path, &file)?;
229    }
230    Ok(removed)
231}
232
233fn write_atomic(path: &Path, file: &KeysFile) -> Result<(), KeyStoreError> {
234    let text = serde_json::to_string_pretty(file)?;
235    if let Some(parent) = path.parent() {
236        if !parent.as_os_str().is_empty() {
237            fs::create_dir_all(parent).map_err(|source| KeyStoreError::Write {
238                path: parent.to_path_buf(),
239                source,
240            })?;
241        }
242    }
243    let tmp = path.with_extension("json.tmp");
244    fs::write(&tmp, text.as_bytes()).map_err(|source| KeyStoreError::Write {
245        path: tmp.clone(),
246        source,
247    })?;
248    #[cfg(unix)]
249    {
250        use std::os::unix::fs::PermissionsExt;
251        let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
252    }
253    fs::rename(&tmp, path).map_err(|source| KeyStoreError::Write {
254        path: path.to_path_buf(),
255        source,
256    })?;
257    Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::scope::Scope;
264
265    #[test]
266    fn empty_store() {
267        let s = KeyStore::empty();
268        assert!(!s.is_configured());
269        assert!(s.verify("anything").is_none());
270    }
271
272    #[test]
273    fn append_and_load() {
274        let dir = tempfile::tempdir().unwrap();
275        let p = dir.path().join("keys.json");
276        let (plaintext, rec) = KeyRecord::generate(
277            "test",
278            [Scope::QotRead].into_iter().collect(),
279            None,
280            None,
281            None,
282        );
283        append_key(&p, rec).unwrap();
284
285        let store = KeyStore::load(&p).unwrap();
286        assert_eq!(store.len(), 1);
287        let found = store.verify(&plaintext).expect("verify ok");
288        assert_eq!(found.id, "test");
289        assert!(store.verify("wrong-plaintext-xxx").is_none());
290    }
291
292    #[test]
293    fn duplicate_id_rejected() {
294        let dir = tempfile::tempdir().unwrap();
295        let p = dir.path().join("keys.json");
296        let (_, rec1) = KeyRecord::generate(
297            "dup",
298            [Scope::QotRead].into_iter().collect(),
299            None,
300            None,
301            None,
302        );
303        append_key(&p, rec1).unwrap();
304        let (_, rec2) = KeyRecord::generate(
305            "dup",
306            [Scope::QotRead].into_iter().collect(),
307            None,
308            None,
309            None,
310        );
311        assert!(matches!(
312            append_key(&p, rec2),
313            Err(KeyStoreError::DuplicateId(_))
314        ));
315    }
316
317    #[test]
318    fn expired_key_not_verified() {
319        let dir = tempfile::tempdir().unwrap();
320        let p = dir.path().join("keys.json");
321        let (plaintext, mut rec) = KeyRecord::generate(
322            "expired",
323            [Scope::QotRead].into_iter().collect(),
324            None,
325            None,
326            None,
327        );
328        rec.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
329        append_key(&p, rec).unwrap();
330
331        let store = KeyStore::load(&p).unwrap();
332        assert!(store.verify(&plaintext).is_none());
333    }
334
335    #[test]
336    fn list_and_remove_key() {
337        let dir = tempfile::tempdir().unwrap();
338        let p = dir.path().join("keys.json");
339        let (pt1, r1) = KeyRecord::generate(
340            "alpha",
341            [Scope::QotRead].into_iter().collect(),
342            None,
343            None,
344            None,
345        );
346        let (_, r2) = KeyRecord::generate(
347            "beta",
348            [Scope::AccRead].into_iter().collect(),
349            None,
350            None,
351            None,
352        );
353        append_key(&p, r1).unwrap();
354        append_key(&p, r2).unwrap();
355
356        let list = list_keys(&p).unwrap();
357        assert_eq!(list.len(), 2);
358        assert!(list.iter().any(|k| k.id == "alpha"));
359        assert!(list.iter().any(|k| k.id == "beta"));
360
361        assert!(remove_key(&p, "alpha").unwrap());
362        assert!(!remove_key(&p, "alpha").unwrap()); // 第二次删找不到
363        let list2 = list_keys(&p).unwrap();
364        assert_eq!(list2.len(), 1);
365        assert_eq!(list2[0].id, "beta");
366
367        // 被删的 key 确实不再验证通过
368        let store = KeyStore::load(&p).unwrap();
369        assert!(store.verify(&pt1).is_none());
370    }
371
372    #[test]
373    fn machine_binding_rejects_mismatch() {
374        let dir = tempfile::tempdir().unwrap();
375        let p = dir.path().join("keys.json");
376        // 用一个绝不会匹配本机的指纹(64 个 0)
377        let fake_fp = "0".repeat(64);
378        let (plaintext, rec) = KeyRecord::generate_with_machines(
379            "bound",
380            [Scope::QotRead].into_iter().collect(),
381            None,
382            None,
383            None,
384            Some(vec![fake_fp]),
385        );
386        append_key(&p, rec).unwrap();
387
388        let store = KeyStore::load(&p).unwrap();
389        // plaintext 正确但机器不匹配 → verify 应返回 None
390        assert!(store.verify(&plaintext).is_none());
391    }
392
393    #[test]
394    fn machine_binding_empty_list_rejects_all() {
395        let dir = tempfile::tempdir().unwrap();
396        let p = dir.path().join("keys.json");
397        let (plaintext, rec) = KeyRecord::generate_with_machines(
398            "frozen",
399            [Scope::QotRead].into_iter().collect(),
400            None,
401            None,
402            None,
403            Some(vec![]), // 空列表 = 冻结
404        );
405        append_key(&p, rec).unwrap();
406
407        let store = KeyStore::load(&p).unwrap();
408        assert!(store.verify(&plaintext).is_none());
409    }
410
411    #[test]
412    fn machine_binding_this_machine_passes() {
413        use crate::machine;
414        let dir = tempfile::tempdir().unwrap();
415        let p = dir.path().join("keys.json");
416        let fp = match machine::fingerprint_for("self-bound") {
417            Ok(fp) => fp,
418            Err(_) => return, // 平台不支持就跳过
419        };
420        let (plaintext, rec) = KeyRecord::generate_with_machines(
421            "self-bound",
422            [Scope::QotRead].into_iter().collect(),
423            None,
424            None,
425            None,
426            Some(vec![fp]),
427        );
428        append_key(&p, rec).unwrap();
429
430        let store = KeyStore::load(&p).unwrap();
431        assert!(store.verify(&plaintext).is_some());
432    }
433
434    #[test]
435    fn reload_picks_up_new_keys() {
436        let dir = tempfile::tempdir().unwrap();
437        let p = dir.path().join("keys.json");
438        let (pt1, r1) = KeyRecord::generate(
439            "a",
440            [Scope::QotRead].into_iter().collect(),
441            None,
442            None,
443            None,
444        );
445        append_key(&p, r1).unwrap();
446        let store = KeyStore::load(&p).unwrap();
447        assert!(store.verify(&pt1).is_some());
448
449        let (pt2, r2) = KeyRecord::generate(
450            "b",
451            [Scope::QotRead].into_iter().collect(),
452            None,
453            None,
454            None,
455        );
456        append_key(&p, r2).unwrap();
457        assert!(store.verify(&pt2).is_none()); // 未 reload
458        store.reload().unwrap();
459        assert!(store.verify(&pt2).is_some());
460    }
461
462    #[test]
463    fn get_by_id_reflects_reload() {
464        // SIGHUP 场景:MCP 用 get_by_id 拿最新 record,reload 后 scope 变更
465        // 可以被立即感知
466        let dir = tempfile::tempdir().unwrap();
467        let p = dir.path().join("keys.json");
468        let (_, rec) = KeyRecord::generate(
469            "hot",
470            [Scope::QotRead].into_iter().collect(),
471            None,
472            None,
473            None,
474        );
475        let id = rec.id.clone();
476        append_key(&p, rec).unwrap();
477        let store = KeyStore::load(&p).unwrap();
478
479        // 初始只有 QotRead
480        let k = store.get_by_id(&id).unwrap();
481        assert!(k.scopes.contains(&Scope::QotRead));
482        assert!(!k.scopes.contains(&Scope::AccRead));
483
484        // 文件外部被改:加一个 scope
485        crate::store::update_key(&p, &id, |r| {
486            r.scopes.insert(Scope::AccRead);
487            true
488        })
489        .unwrap();
490        // reload 前还是老的
491        assert!(!store
492            .get_by_id(&id)
493            .unwrap()
494            .scopes
495            .contains(&Scope::AccRead));
496        // reload 后立刻看得到
497        store.reload().unwrap();
498        assert!(store
499            .get_by_id(&id)
500            .unwrap()
501            .scopes
502            .contains(&Scope::AccRead));
503
504        // 删掉 key 后 get_by_id 返回 None(MCP guard 视为吊销)
505        remove_key(&p, &id).unwrap();
506        store.reload().unwrap();
507        assert!(store.get_by_id(&id).is_none());
508    }
509}