Skip to main content

futu_auth/
store.rs

1//! KeyStore: keys.json 加载 + 热替换 + 明文验证
2
3mod lock;
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use arc_swap::ArcSwap;
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13
14use crate::key::{KeyRecord, hash_plaintext};
15use lock::AdvisoryLockGuard;
16
17#[derive(Debug, thiserror::Error)]
18#[non_exhaustive]
19pub enum KeyStoreError {
20    #[error("read {path:?}: {source}")]
21    Read {
22        path: PathBuf,
23        source: std::io::Error,
24    },
25    #[error("parse {path:?}: {source}")]
26    Parse {
27        path: PathBuf,
28        source: serde_json::Error,
29    },
30    #[error("write {path:?}: {source}")]
31    Write {
32        path: PathBuf,
33        source: std::io::Error,
34    },
35    #[error("serialize: {0}")]
36    Serialize(#[from] serde_json::Error),
37    #[error("unsupported keys.json version {0} (supported readers: 1, 2; writer: 2)")]
38    UnsupportedVersion(u32),
39    #[error("duplicate key id {0:?}")]
40    DuplicateId(String),
41    #[error(
42        "cannot persist a KeyRecord with card-number restrictions and effective allowed_acc_ids but no file-source snapshot"
43    )]
44    AmbiguousAccountSource,
45}
46
47/// keys.json 顶层文件结构
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct KeysFile {
51    pub version: u32,
52    pub keys: Vec<KeyRecord>,
53}
54
55const CURRENT_VERSION: u32 = 2;
56
57/// KeyStore:热可替换的 keys 集合
58#[derive(Debug)]
59pub struct KeyStore {
60    path: Option<PathBuf>,
61    current: ArcSwap<KeyStoreGeneration>,
62    generation_writer: parking_lot::Mutex<()>,
63}
64
65#[derive(Debug)]
66struct KeyStoreGeneration {
67    version: u32,
68    records: Vec<Arc<KeyRecord>>,
69    by_hash: HashMap<String, Vec<Arc<KeyRecord>>>,
70    by_id: HashMap<String, Arc<KeyRecord>>,
71}
72
73impl KeyStoreGeneration {
74    fn from_file(file: KeysFile) -> Self {
75        let mut records = Vec::with_capacity(file.keys.len());
76        let mut by_hash: HashMap<String, Vec<Arc<KeyRecord>>> =
77            HashMap::with_capacity(file.keys.len());
78        let mut by_id = HashMap::with_capacity(file.keys.len());
79        for record in file.keys {
80            let record = Arc::new(record);
81            by_hash
82                .entry(record.hash.clone())
83                .or_default()
84                .push(Arc::clone(&record));
85            by_id.insert(record.id.clone(), Arc::clone(&record));
86            records.push(record);
87        }
88        Self {
89            version: file.version,
90            records,
91            by_hash,
92            by_id,
93        }
94    }
95
96    fn to_file(&self) -> KeysFile {
97        KeysFile {
98            version: self.version,
99            keys: self
100                .records
101                .iter()
102                .map(|record| record.as_ref().clone())
103                .collect(),
104        }
105    }
106}
107
108impl KeyStore {
109    /// 空 store(没有 keys 文件时)
110    pub fn empty() -> Self {
111        let file = KeysFile {
112            version: CURRENT_VERSION,
113            keys: vec![],
114        };
115        Self {
116            path: None,
117            current: ArcSwap::from_pointee(KeyStoreGeneration::from_file(file)),
118            generation_writer: parking_lot::Mutex::new(()),
119        }
120    }
121
122    /// 从文件加载
123    pub fn load(path: impl Into<PathBuf>) -> Result<Self, KeyStoreError> {
124        let path = path.into();
125        let file = Self::load_file(&path)?;
126        Ok(Self {
127            path: Some(path),
128            current: ArcSwap::from_pointee(KeyStoreGeneration::from_file(file)),
129            generation_writer: parking_lot::Mutex::new(()),
130        })
131    }
132
133    fn load_file(path: &Path) -> Result<KeysFile, KeyStoreError> {
134        if !path.exists() {
135            return Self::load_file_unlocked(path);
136        }
137        let _guard = AdvisoryLockGuard::acquire_shared(path)?;
138        Self::load_runtime_file_unlocked(path)
139    }
140
141    /// Parse and validate the persisted source representation without adding
142    /// runtime-only sentinels or card-number resolutions. RMW paths must use
143    /// this view so derived authorization state can never leak back to disk.
144    fn load_file_unlocked(path: &Path) -> Result<KeysFile, KeyStoreError> {
145        let text = fs::read_to_string(path).map_err(|source| KeyStoreError::Read {
146            path: path.to_path_buf(),
147            source,
148        })?;
149        let file: KeysFile =
150            serde_json::from_str(&text).map_err(|source| KeyStoreError::Parse {
151                path: path.to_path_buf(),
152                source,
153            })?;
154        if !matches!(file.version, 1 | CURRENT_VERSION) {
155            return Err(KeyStoreError::UnsupportedVersion(file.version));
156        }
157        // 检查重复 id
158        let mut seen = std::collections::HashSet::new();
159        for k in &file.keys {
160            if !seen.insert(k.id.clone()) {
161                return Err(KeyStoreError::DuplicateId(k.id.clone()));
162            }
163        }
164        Ok(file)
165    }
166
167    fn load_runtime_file_unlocked(path: &Path) -> Result<KeysFile, KeyStoreError> {
168        let mut file = Self::load_file_unlocked(path)?;
169        // v1.4.104 external reviewer S-002 (P0) fix: load 时立即注入 fail-closed sentinel —
170        //
171        // 之前 expand_allowed_card_nums 只在 daemon 启动 + SIGHUP 跑, 但 MCP 等
172        // keystore consumer **不调** expand → 受 `allowed_card_nums` 限制的 key
173        // 加载后 `allowed_acc_ids = None`, 被限额引擎当作 "无限制" silent allow.
174        //
175        // **修法**: load_file 时对每条 key, 若 `allowed_card_nums` 非空但
176        // `allowed_acc_ids` 是 None / empty → 注入 sentinel `Some({0})`. 真实
177        // expansion (e.g. opend daemon GetAccList 之后) 会以 resolved acc_ids 覆盖.
178        // MCP 等不跑 expand 的消费方仍受 sentinel 保护 (fail-closed: real acc_id
179        // ≠ 0 → 永远 reject).
180        //
181        // 这是架构层 fix (与 v1.4.103 codex F1 P1 expand-time sentinel 同语义,
182        // 但提前到 load 时让所有 consumer 受益, 不依赖每个消费方都调 expand).
183        for rec in &mut file.keys {
184            // v1.4.106 F-P2-D: snapshot 文件源原始 allowed_acc_ids (sentinel
185            // 注入和 card_num expansion 之前). expand_allowed_card_nums 用
186            // 此字段作起步, 防止累积 stale resolutions.
187            rec.raw_explicit_acc_ids = rec.allowed_acc_ids.clone();
188
189            let has_card_nums = rec
190                .allowed_card_nums
191                .as_ref()
192                .is_some_and(|v| !v.is_empty());
193            let has_acc_ids = rec.allowed_acc_ids.as_ref().is_some_and(|s| !s.is_empty());
194            if has_card_nums && !has_acc_ids {
195                // 写 sentinel acc_id=0; expand_allowed_card_nums 后续会用
196                // resolved acc_ids 覆盖. 此期间任何 acc_id ≠ 0 的 query 全 reject.
197                let mut sentinel = rec.allowed_acc_ids.clone().unwrap_or_default();
198                sentinel.insert(0);
199                rec.allowed_acc_ids = Some(sentinel);
200                let key_id = crate::metrics::redact_key_id_for_logs(&rec.id);
201                let card_num_count = rec.allowed_card_nums.as_ref().map_or(0, Vec::len);
202                tracing::warn!(
203                    key_id = %key_id,
204                    card_num_count,
205                    "v1.4.104 external report S-002 (P0): keystore load 注入 fail-closed sentinel \
206                     allowed_acc_ids={{0}} (caller 配 allowed_card_nums 但 daemon 还没\
207                     expand). expand_allowed_card_nums 跑完后真实 resolved acc_ids 覆盖. \
208                     MCP / 不跑 expand 的 consumer 仍按 sentinel 保护."
209                );
210            }
211        }
212        Ok(file)
213    }
214
215    /// SIGHUP 热重载:用同一路径重新读文件
216    pub fn reload(&self) -> Result<(), KeyStoreError> {
217        let Some(path) = &self.path else {
218            return Ok(());
219        };
220        let _writer = self.generation_writer.lock();
221        let file = Self::load_file(path)?;
222        self.current
223            .store(Arc::new(KeyStoreGeneration::from_file(file)));
224        Ok(())
225    }
226
227    /// v1.4.103 (B10): 把每条 key 的 `allowed_card_nums` (string format) 通过
228    /// `resolver` 解析成 acc_id, **合并**进 `allowed_acc_ids` (in-memory only,
229    /// 不写回 keys.json — 文件源不变, 重载后再 expand).
230    ///
231    /// `resolver(card_num) -> Vec<u64>` 由 caller 提供 (典型 closure 持
232    /// `Arc<TrdCache>` 调 `find_acc_ids_by_card_num`).
233    ///
234    /// **行为**:
235    /// - resolver 返 1 个 acc_id → 加入 allowed_acc_ids (resolved)
236    /// - 返 0 个 → 通过 `unresolved_callback` 通知 caller (e.g. log warn)
237    /// - 返 ≥ 2 个 → 通过 `ambiguous_callback` 通知 caller (loud, skip 该条)
238    ///
239    /// 返 `(resolved_count, unresolved_count, ambiguous_count)`.
240    ///
241    /// **典型调用 (daemon 启动 GetAccList 成功后)**:
242    /// ```ignore
243    /// let cache_clone = trd_cache.clone();
244    /// key_store.expand_allowed_card_nums(
245    ///     |cn: &str| cache_clone.find_acc_ids_by_card_num(cn),
246    ///     |key_id, cn| tracing::warn!(key_id, card_num=cn, "card_num not found"),
247    ///     |key_id, cn, candidates| tracing::warn!(key_id, card_num=cn, ?candidates, "ambiguous card_num"),
248    /// );
249    /// ```
250    pub fn expand_allowed_card_nums<R, FU, FA>(
251        &self,
252        resolver: R,
253        mut unresolved_callback: FU,
254        mut ambiguous_callback: FA,
255    ) -> (usize, usize, usize)
256    where
257        R: Fn(&str) -> Vec<u64>,
258        FU: FnMut(&str, &str),         // key_id, card_num
259        FA: FnMut(&str, &str, &[u64]), // key_id, card_num, candidates
260    {
261        let mut resolution_cache: HashMap<String, Vec<u64>> = HashMap::new();
262        loop {
263            let base = self.current.load_full();
264            let mut file = base.to_file();
265            let mut resolved = 0;
266            let mut unresolved = 0;
267            let mut ambiguous = 0;
268            let mut unresolved_events = Vec::new();
269            let mut ambiguous_events = Vec::new();
270            for rec in &mut file.keys {
271                let Some(card_nums) = rec.allowed_card_nums.clone() else {
272                    continue;
273                };
274                // v1.4.106 F-P2-D: 从 raw_explicit_acc_ids 起步重新 resolve, 不
275                // 累积 stale resolutions. 之前 `rec.allowed_acc_ids.clone()` 起
276                // 步会让连续 expand 累积 — 若 keys.json 没动但 cache 里某 acc 不
277                // 再可见, 旧 resolved acc_id 仍留在 allowed set 中. 现在每次
278                // expand 都从 file 源原始集合重新计算. raw 为 None → 空集起步.
279                let mut acc_ids = rec.raw_explicit_acc_ids.clone().unwrap_or_default();
280                for cn in &card_nums {
281                    let candidates = resolution_cache
282                        .entry(cn.clone())
283                        .or_insert_with(|| resolver(cn));
284                    match candidates.len() {
285                        0 => {
286                            unresolved += 1;
287                            unresolved_events.push((rec.id.clone(), cn.clone()));
288                        }
289                        1 => {
290                            acc_ids.insert(candidates[0]);
291                            resolved += 1;
292                        }
293                        _ => {
294                            ambiguous += 1;
295                            ambiguous_events.push((rec.id.clone(), cn.clone(), candidates.clone()));
296                        }
297                    }
298                }
299                // v1.4.103 codex F1 (P1) fail-closed: 无论 acc_ids 是否非空, 都
300                // **必须** 写入 Some(...) — 哪怕是空 HashSet (= "denylist 全部",
301                // 限额引擎 step 0 acc_id 白名单非空 + 不含 ctx.acc_id → reject).
302                //
303                // 旧逻辑 (silent unrestricted): `if !acc_ids.is_empty() { rec.allowed_acc_ids = Some(acc_ids); }`
304                // 当 caller 配置 allowed_card_nums 但**全部 unresolved/ambiguous** 时,
305                // acc_ids 留空, allowed_acc_ids 仍 None → 限额引擎按 "无限制" 处理 →
306                // 受限 key silent unrestricted (反模式 D / pitfall #45).
307                //
308                // 新逻辑: 只要 caller 显式写了 allowed_card_nums (说明 *intent* 是限制),
309                // 就强制 Some(acc_ids) — 即便空集. 限额引擎检测到 Some(empty) 时
310                // 视为 "全 reject" (限额 step 0 `allowed.is_empty()` 已 short-circuit
311                // 不 reject, 但 contains check 永远 false → reject).
312                //
313                // **wait**: 看 limits.rs:332 `check_full_skip_rate`, step 0 是
314                // `if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id) && !allowed.is_empty() && !allowed.contains(&id)`.
315                // 关键: `!allowed.is_empty()` short-circuit empty set, 等于 "无限制".
316                // 这就是 silent-unrestricted 的根源. 但 `allowed.is_empty()` 短路是
317                // **故意的语义** (允许 None / 空集都视为 "不限制") — 改这个会破坏
318                // 现有 user contract.
319                //
320                // **正确修法**: 既然空集语义 = 无限制不能改, 我们要把 caller intent
321                // (allowed_card_nums 非空) → 限额能识别 "想限但无法 resolve" 的状态.
322                // 选: 把 sentinel acc_id (e.g. 0) 写入 acc_ids 触发 reject — 因为
323                // 没有真账户 acc_id == 0. 限额检查时 acc_ids = {0}, ctx.acc_id =
324                // <real id> ≠ 0 → reject. legitimate id 也 reject — 这是
325                // fail-closed 保守语义.
326                if !card_nums.is_empty() {
327                    if acc_ids.is_empty() {
328                        // 全部 unresolved/ambiguous → 写 sentinel 0 让限额 reject 一切
329                        acc_ids.insert(0u64);
330                    }
331                    rec.allowed_acc_ids = Some(acc_ids);
332                } else if !acc_ids.is_empty() {
333                    rec.allowed_acc_ids = Some(acc_ids);
334                }
335            }
336
337            let writer = self.generation_writer.lock();
338            if !Arc::ptr_eq(&base, &self.current.load_full()) {
339                drop(writer);
340                continue;
341            }
342            self.current
343                .store(Arc::new(KeyStoreGeneration::from_file(file)));
344            drop(writer);
345
346            for (key_id, card_num) in unresolved_events {
347                unresolved_callback(&key_id, &card_num);
348            }
349            for (key_id, card_num, candidates) in ambiguous_events {
350                ambiguous_callback(&key_id, &card_num, &candidates);
351            }
352            return (resolved, unresolved, ambiguous);
353        }
354    }
355
356    /// 明文校验:遍历所有未过期 key,匹配则返回 KeyRecord 快照
357    ///
358    /// 如果 key 设置了 `allowed_machines` 且本机不在白名单,会打 warn 日志并视为未匹配。
359    /// 这样做法的代价:攻击者可以通过"能不能过"侧信道区分 key 是否存在 — 我们接受,
360    /// 因为 plaintext 空间是 256 bit 随机 hex,侧信道没意义。
361    pub fn verify(&self, plaintext: &str) -> Option<Arc<KeyRecord>> {
362        if !KeyRecord::is_generated_plaintext_shape(plaintext) {
363            return None;
364        }
365        let computed_hash = hash_plaintext(plaintext);
366        let generation = self.current.load();
367        let now = Utc::now();
368        let candidates = generation.by_hash.get(&computed_hash)?;
369        for k in candidates {
370            if k.is_expired(now) {
371                continue;
372            }
373            if let Err(e) = k.check_machine() {
374                let key_id = crate::metrics::redact_key_id_for_logs(&k.id);
375                tracing::warn!(
376                    key_id = %key_id,
377                    error = %e,
378                    "api key matched but machine binding failed; rejecting"
379                );
380                return None;
381            }
382            return Some(Arc::clone(k));
383        }
384        None
385    }
386
387    /// 是否显式加载了 keys 文件。
388    ///
389    /// 注意:空 keys 文件也算 configured,用于让 surface 进入 scope mode
390    /// 并 fail-closed;只有 [`Self::empty`] 才代表 legacy/no-key 模式。
391    #[must_use]
392    pub fn is_configured(&self) -> bool {
393        self.path.is_some()
394    }
395
396    pub fn path(&self) -> Option<&Path> {
397        self.path.as_deref()
398    }
399
400    #[must_use]
401    pub fn len(&self) -> usize {
402        self.current.load().records.len()
403    }
404
405    #[must_use]
406    pub fn is_empty(&self) -> bool {
407        self.len() == 0
408    }
409
410    #[must_use]
411    pub fn has_usable_scope(&self, scope: crate::Scope) -> bool {
412        let now = Utc::now();
413        self.current.load().records.iter().any(|record| {
414            record.scopes.contains(&scope)
415                && !record.is_expired(now)
416                && record.check_machine().is_ok()
417        })
418    }
419
420    /// v1.4.105 external reviewer #4 fix: 当前 KeyStore 是否有任意 key 配置了
421    /// `allowed_card_nums` 限制. 用于 standalone MCP / gRPC / 任何不持
422    /// `TrdCache` 的 keystore consumer 在启动时判断:
423    /// - `false` → 没有 card_num 限制, 跳过 daemon `GetAccList` + expand 全流程
424    ///   (避免无意义的 daemon 请求)
425    /// - `true` → 必须连 daemon, 调 `GetAccList`, 通过
426    ///   [`Self::expand_allowed_card_nums`] 把 card_num resolve 成 acc_id;
427    ///   否则 fail-closed sentinel `{0}` 会让所有真账户 reject (external reviewer BUG
428    ///   v1.4.104-002: standalone MCP 漏调 expand 导致 `0757` 配置的 key
429    ///   全 reject).
430    ///
431    /// 注意: 本方法只检查 raw `allowed_card_nums` 是否非空 — load_file 阶段
432    /// 注入的 sentinel `allowed_acc_ids = {0}` **不**算"已 expand"; 只有
433    /// caller 真跑过 [`Self::expand_allowed_card_nums`] 后才会用 resolved
434    /// acc_ids 覆盖 sentinel.
435    #[must_use]
436    pub fn has_any_card_num_restrictions(&self) -> bool {
437        self.current
438            .load()
439            .records
440            .iter()
441            .any(|k| k.allowed_card_nums.as_ref().is_some_and(|v| !v.is_empty()))
442    }
443
444    /// 按 id 查询当前快照中的 key(**不做 expiry / machine 校验**,调用方自己做)
445    ///
446    /// 典型用法:MCP 在启动时 `verify(plaintext)` 拿到 id,后续每个请求用
447    /// `get_by_id` 取最新记录,这样 SIGHUP 重载 keys.json 后 scope / 限额 /
448    /// expires_at 的变更能立刻生效(不用重启进程)。
449    ///
450    /// 返回 None 表示 id 在当前文件里不存在(被 remove_key 删掉了),调用方应
451    /// 视为"key 已吊销"直接拒绝。
452    ///
453    /// **注意**:此方法**不做 machine binding 校验**。对于跨 SIGHUP 的 per-msg /
454    /// per-tool 复检场景应改用 [`Self::get_by_id_for_current_machine`],确保
455    /// SIGHUP 后新加的 `allowed_machines` 限制立即生效(避免 startup 验过 →
456    /// SIGHUP 收紧 → 仍按老 record 放行的语义漂移)。
457    pub fn get_by_id(&self, id: &str) -> Option<Arc<KeyRecord>> {
458        self.current.load().by_id.get(id).map(Arc::clone)
459    }
460
461    /// 按 id 查询当前快照中的 key + 立即校验本机 machine binding。
462    ///
463    /// **统一生命周期入口**: 任何 surface (WS / MCP / REST / gRPC) 在
464    /// 已 verify-once → 跨 SIGHUP 复检场景下应使用此方法替代裸 [`Self::get_by_id`],
465    /// 避免如下漂移:
466    ///
467    /// - startup 时 `verify(plaintext)` 检查 machine binding ✅
468    /// - SIGHUP reload 把该 key 的 `allowed_machines` 收紧(移除本机指纹)
469    /// - 后续 per-msg / per-tool 仅调 `get_by_id` → **绕过 machine binding** →
470    ///   silent unrestricted (反模式 D / pitfall #45 silent-success 同模式)
471    ///
472    /// 行为:
473    /// - id 不存在 → `None`(key 已被 remove_key 吊销,caller 视为吊销拒绝)
474    /// - id 存在 + machine 校验通过 → `Some(rec)`
475    /// - id 存在 + machine 校验失败 → `None` + warn log(与 `verify` 同语义)
476    ///
477    /// **不做 expiry 校验** —— pipeline.rs Step 1.5 / caller 自己做(与
478    /// `get_by_id` 行为对齐,仅差 machine 一层)。
479    pub fn get_by_id_for_current_machine(&self, id: &str) -> Option<Arc<KeyRecord>> {
480        let rec = self.get_by_id(id)?;
481        if let Err(e) = rec.check_machine() {
482            let key_id = crate::metrics::redact_key_id_for_logs(&rec.id);
483            tracing::warn!(
484                key_id = %key_id,
485                error = %e,
486                "api key get_by_id_for_current_machine: machine binding failed; \
487                 treating as revoked (caller should reject as if key not found)"
488            );
489            return None;
490        }
491        Some(rec)
492    }
493
494    /// 导出当前所有 keys 的 id(用于调试 / 审计)
495    #[must_use]
496    pub fn ids(&self) -> Vec<String> {
497        self.current
498            .load()
499            .records
500            .iter()
501            .map(|k| k.id.clone())
502            .collect()
503    }
504}
505
506/// 追加一条新 key 到 keys.json(atomic rename)
507/// v1.4.106 codex 0558 F5 (P2): RMW (read-modify-write) helper that holds the
508/// flock for the **entire** sequence — load, mutate, write. Without this,
509/// concurrent append_key callers can load → load → write → write and lose
510/// the first writer's record.
511fn with_keys_lock<F, R>(path: &Path, f: F) -> Result<R, KeyStoreError>
512where
513    F: FnOnce(&Path) -> Result<R, KeyStoreError>,
514{
515    if let Some(parent) = path.parent()
516        && !parent.as_os_str().is_empty()
517    {
518        fs::create_dir_all(parent).map_err(|source| KeyStoreError::Write {
519            path: parent.to_path_buf(),
520            source,
521        })?;
522    }
523    let _guard = AdvisoryLockGuard::acquire_exclusive(path)?;
524    f(path)
525}
526
527pub fn append_key(path: &Path, mut new_record: KeyRecord) -> Result<(), KeyStoreError> {
528    if new_record.raw_explicit_acc_ids.is_none() {
529        let has_card_nums = new_record
530            .allowed_card_nums
531            .as_ref()
532            .is_some_and(|values| !values.is_empty());
533        let has_effective_acc_ids = new_record
534            .allowed_acc_ids
535            .as_ref()
536            .is_some_and(|values| !values.is_empty());
537        if has_card_nums && has_effective_acc_ids {
538            return Err(KeyStoreError::AmbiguousAccountSource);
539        }
540        // Public callers may construct KeyRecord directly instead of using
541        // KeyRecord::generate. Snapshot their explicit file-source intent
542        // before serialization. A card-number-constrained record with an
543        // effective account set and no snapshot is ambiguous (it may be a
544        // runtime sentinel/resolution), so the branch above rejects it.
545        new_record.raw_explicit_acc_ids = new_record.allowed_acc_ids.clone();
546    }
547    with_keys_lock(path, |path| {
548        let mut file = match fs::metadata(path) {
549            Ok(_) => KeyStore::load_file_unlocked(path)?,
550            Err(_) => KeysFile {
551                version: CURRENT_VERSION,
552                keys: vec![],
553            },
554        };
555        if file.keys.iter().any(|k| k.id == new_record.id) {
556            return Err(KeyStoreError::DuplicateId(new_record.id));
557        }
558        file.version = CURRENT_VERSION;
559        file.keys.push(new_record);
560        write_atomic_inner(path, &file)
561    })
562}
563
564/// 读取 keys.json 并返回所有记录快照(展示用;不暴露 hash 以外的敏感位)
565pub fn list_keys(path: &Path) -> Result<Vec<KeyRecord>, KeyStoreError> {
566    let file = KeyStore::load_file(path)?;
567    Ok(file.keys)
568}
569
570/// 按 id 编辑一条 key(atomic rename);闭包返回 `false` 代表未改动 → 跳过落盘
571///
572/// 适用于就地修改 `allowed_machines` / `expires_at` / `note` 等配置,
573/// 而不想走 "revoke + regen" 流程(否则 plaintext 会换)。
574pub fn update_key<F>(path: &Path, id: &str, mutate: F) -> Result<bool, KeyStoreError>
575where
576    F: FnOnce(&mut KeyRecord) -> bool,
577{
578    // v1.4.106 F5: 整个 RMW 在 flock 内, 防 concurrent update_key 互相覆盖
579    with_keys_lock(path, |path| {
580        let mut file = KeyStore::load_file_unlocked(path)?;
581        let Some(rec) = file.keys.iter_mut().find(|k| k.id == id) else {
582            return Ok(false);
583        };
584        let allowed_acc_ids_before = rec.allowed_acc_ids.clone();
585        let changed = mutate(rec);
586        if changed {
587            if rec.allowed_acc_ids != allowed_acc_ids_before {
588                // update_key is public and may intentionally edit the explicit
589                // account whitelist. Only such an edit replaces the source
590                // snapshot; unrelated bind/note updates must not persist the
591                // runtime sentinel or card-number expansion result.
592                rec.raw_explicit_acc_ids = rec.allowed_acc_ids.clone();
593            }
594            file.version = CURRENT_VERSION;
595            write_atomic_inner(path, &file)?;
596        }
597        Ok(changed)
598    })
599}
600
601/// 按 id 删除一条 key(atomic rename);返回是否真的删掉了一条
602pub fn remove_key(path: &Path, id: &str) -> Result<bool, KeyStoreError> {
603    // v1.4.106 F5: 整个 RMW 在 flock 内
604    with_keys_lock(path, |path| {
605        let mut file = KeyStore::load_file_unlocked(path)?;
606        let before = file.keys.len();
607        file.keys.retain(|k| k.id != id);
608        let removed = before != file.keys.len();
609        if removed {
610            file.version = CURRENT_VERSION;
611            write_atomic_inner(path, &file)?;
612        }
613        Ok(removed)
614    })
615}
616
617/// v1.4.106 codex 0558 F5 (P2): atomic write with advisory flock + unique
618/// tempfile + fsync.
619///
620/// **背景**: 之前的 write_atomic 有 3 个问题:
621///   1. `tmp = path.with_extension("json.tmp")` — 同 path **每个 process 共
622///      享**, 并发写 (daemon + futucli + multiple admin reload) 会互相覆写
623///      tempfile, 最后 rename 时数据交错.
624///   2. **无 advisory flock** — 没有跨 process coordination, race 可让 reader
625///      看到部分写完的 `keys.json` (rename 前如果别的 process 也在 truncate).
626///   3. **无 fsync** — 写完立刻 rename 在 ext4 数据=writeback 模式 / SSD power
627///      loss 下可能丢内容 (file 在 inode 层面存在但 data block 没 flush).
628///
629/// **修法**: 1) tempfile 名带 pid + nanos: `keys.json.<pid>.<nanos>.tmp` — race-free.
630/// 2) 对 `keys.json.lock` (sibling lock file) 取 LOCK_EX flock; 读路径
631/// (load_file) 取 LOCK_SH; RMW 调用方在已经持有 LOCK_EX 时走
632/// `load_file_unlocked` 避免同线程嵌套锁. 3) tempfile open 后 write_all →
633/// sync_all → close → set_permissions → rename → 持有 lock 期间.
634///
635/// v1.4.106 codex 0558 F5: 写盘只做 unique tempfile + fsync + rename. flock 由
636/// caller (with_keys_lock) 在 RMW 范围统一持有, 这里**不再**单独加锁 (避免
637/// 与 with_keys_lock 重入).
638fn write_atomic_inner(path: &Path, file: &KeysFile) -> Result<(), KeyStoreError> {
639    let text = serde_json::to_string_pretty(file)?;
640
641    // v1.4.106 F5: unique tempfile (pid + nanos), 防 concurrent rename 战 tempfile.
642    let nanos = keystore_tempfile_nanos_or_zero();
643    let tmp_name = match path.file_name().and_then(|n| n.to_str()) {
644        Some(name) => format!(
645            "{name}.{pid}.{nanos}.tmp",
646            pid = std::process::id(),
647            nanos = nanos,
648        ),
649        None => format!(
650            "keys.{pid}.{nanos}.tmp",
651            pid = std::process::id(),
652            nanos = nanos,
653        ),
654    };
655    let tmp = path
656        .parent()
657        .map(|p| p.join(&tmp_name))
658        .unwrap_or_else(|| Path::new(&tmp_name).to_path_buf());
659
660    // 写入 tempfile + fsync — 0600 mode 通过 OpenOptions (Unix) 创建时即生效.
661    use std::io::Write;
662    #[cfg(unix)]
663    let mut f = {
664        use std::os::unix::fs::OpenOptionsExt;
665        fs::OpenOptions::new()
666            .create_new(true)
667            .write(true)
668            .mode(0o600)
669            .open(&tmp)
670            .map_err(|source| KeyStoreError::Write {
671                path: tmp.clone(),
672                source,
673            })?
674    };
675    #[cfg(not(unix))]
676    let mut f = fs::OpenOptions::new()
677        .create_new(true)
678        .write(true)
679        .open(&tmp)
680        .map_err(|source| KeyStoreError::Write {
681            path: tmp.clone(),
682            source,
683        })?;
684
685    let write_res = f
686        .write_all(text.as_bytes())
687        .and_then(|_| f.sync_all())
688        .map_err(|source| KeyStoreError::Write {
689            path: tmp.clone(),
690            source,
691        });
692    drop(f);
693
694    if let Err(e) = write_res {
695        if let Err(cleanup_err) = fs::remove_file(&tmp) {
696            tracing::debug!(
697                path = %tmp.display(),
698                error = %cleanup_err,
699                "keystore atomic write failed; tempfile cleanup also failed"
700            );
701        }
702        return Err(e);
703    }
704
705    // 防御 chmod (Unix), OpenOptions.mode 已 0600, 但部分 fs / umask 异常时兜底.
706    #[cfg(unix)]
707    {
708        use std::os::unix::fs::PermissionsExt;
709        if let Err(err) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)) {
710            tracing::warn!(
711                path = %tmp.display(),
712                error = %err,
713                "keystore tempfile chmod 0600 failed"
714            );
715        }
716    }
717
718    // atomic rename → tempfile 替换 inode, reader 看到的永远是完整 file.
719    fs::rename(&tmp, path).map_err(|source| KeyStoreError::Write {
720        path: path.to_path_buf(),
721        source,
722    })?;
723
724    Ok(())
725}
726
727fn keystore_tempfile_nanos_or_zero() -> u128 {
728    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
729        Ok(duration) => duration.as_nanos(),
730        Err(err) => {
731            tracing::warn!(
732                error = ?err,
733                "keystore wall clock is before UNIX_EPOCH; falling back to zero tempfile timestamp"
734            );
735            0
736        }
737    }
738}
739
740#[cfg(test)]
741mod tests;