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;