1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use arc_swap::ArcSwap;
9use chrono::Utc;
10use serde::{Deserialize, Serialize};
11
12use crate::key::{KeyRecord, hash_plaintext};
13
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum KeyStoreError {
17 #[error("read {path:?}: {source}")]
18 Read {
19 path: PathBuf,
20 source: std::io::Error,
21 },
22 #[error("parse {path:?}: {source}")]
23 Parse {
24 path: PathBuf,
25 source: serde_json::Error,
26 },
27 #[error("write {path:?}: {source}")]
28 Write {
29 path: PathBuf,
30 source: std::io::Error,
31 },
32 #[error("serialize: {0}")]
33 Serialize(#[from] serde_json::Error),
34 #[error("unsupported keys.json version {0} (supported: 1)")]
35 UnsupportedVersion(u32),
36 #[error("duplicate key id {0:?}")]
37 DuplicateId(String),
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct KeysFile {
43 pub version: u32,
44 pub keys: Vec<KeyRecord>,
45}
46
47const CURRENT_VERSION: u32 = 1;
48
49#[derive(Debug)]
51pub struct KeyStore {
52 path: Option<PathBuf>,
53 current: ArcSwap<KeysFile>,
54 hash_index: ArcSwap<HashMap<String, Vec<KeyRecord>>>,
55}
56
57impl KeyStore {
58 pub fn empty() -> Self {
60 let file = KeysFile {
61 version: CURRENT_VERSION,
62 keys: vec![],
63 };
64 let hash_index = Self::build_hash_index(&file);
65 Self {
66 path: None,
67 current: ArcSwap::from_pointee(file),
68 hash_index: ArcSwap::from_pointee(hash_index),
69 }
70 }
71
72 pub fn load(path: impl Into<PathBuf>) -> Result<Self, KeyStoreError> {
74 let path = path.into();
75 let file = Self::load_file(&path)?;
76 let hash_index = Self::build_hash_index(&file);
77 Ok(Self {
78 path: Some(path),
79 current: ArcSwap::from_pointee(file),
80 hash_index: ArcSwap::from_pointee(hash_index),
81 })
82 }
83
84 fn build_hash_index(file: &KeysFile) -> HashMap<String, Vec<KeyRecord>> {
85 let mut index: HashMap<String, Vec<KeyRecord>> = HashMap::with_capacity(file.keys.len());
86 for rec in &file.keys {
87 index.entry(rec.hash.clone()).or_default().push(rec.clone());
88 }
89 index
90 }
91
92 fn load_file(path: &Path) -> Result<KeysFile, KeyStoreError> {
93 if !path.exists() {
94 return Self::load_file_unlocked(path);
95 }
96 let _guard = AdvisoryLockGuard::acquire_shared(path)?;
97 Self::load_file_unlocked(path)
98 }
99
100 fn load_file_unlocked(path: &Path) -> Result<KeysFile, KeyStoreError> {
101 let text = fs::read_to_string(path).map_err(|source| KeyStoreError::Read {
102 path: path.to_path_buf(),
103 source,
104 })?;
105 let mut file: KeysFile =
106 serde_json::from_str(&text).map_err(|source| KeyStoreError::Parse {
107 path: path.to_path_buf(),
108 source,
109 })?;
110 if file.version != CURRENT_VERSION {
111 return Err(KeyStoreError::UnsupportedVersion(file.version));
112 }
113 let mut seen = std::collections::HashSet::new();
115 for k in &file.keys {
116 if !seen.insert(k.id.clone()) {
117 return Err(KeyStoreError::DuplicateId(k.id.clone()));
118 }
119 }
120 for rec in &mut file.keys {
135 rec.raw_explicit_acc_ids = rec.allowed_acc_ids.clone();
139
140 let has_card_nums = rec
141 .allowed_card_nums
142 .as_ref()
143 .is_some_and(|v| !v.is_empty());
144 let has_acc_ids = rec.allowed_acc_ids.as_ref().is_some_and(|s| !s.is_empty());
145 if has_card_nums && !has_acc_ids {
146 let mut sentinel = rec.allowed_acc_ids.clone().unwrap_or_default();
149 sentinel.insert(0);
150 rec.allowed_acc_ids = Some(sentinel);
151 let key_id = crate::metrics::redact_key_id_for_logs(&rec.id);
152 let card_num_count = rec.allowed_card_nums.as_ref().map_or(0, Vec::len);
153 tracing::warn!(
154 key_id = %key_id,
155 card_num_count,
156 "v1.4.104 external report S-002 (P0): keystore load 注入 fail-closed sentinel \
157 allowed_acc_ids={{0}} (caller 配 allowed_card_nums 但 daemon 还没\
158 expand). expand_allowed_card_nums 跑完后真实 resolved acc_ids 覆盖. \
159 MCP / 不跑 expand 的 consumer 仍按 sentinel 保护."
160 );
161 }
162 }
163 Ok(file)
164 }
165
166 pub fn reload(&self) -> Result<(), KeyStoreError> {
168 let Some(path) = &self.path else {
169 return Ok(());
170 };
171 let file = Self::load_file(path)?;
172 let hash_index = Self::build_hash_index(&file);
173 self.current.store(Arc::new(file));
174 self.hash_index.store(Arc::new(hash_index));
175 Ok(())
176 }
177
178 pub fn expand_allowed_card_nums<R, FU, FA>(
202 &self,
203 resolver: R,
204 mut unresolved_callback: FU,
205 mut ambiguous_callback: FA,
206 ) -> (usize, usize, usize)
207 where
208 R: Fn(&str) -> Vec<u64>,
209 FU: FnMut(&str, &str), FA: FnMut(&str, &str, &[u64]), {
212 let current = self.current.load();
213 let mut new_keys = current.keys.clone();
214 let mut resolved = 0;
215 let mut unresolved = 0;
216 let mut ambiguous = 0;
217 for rec in &mut new_keys {
218 let Some(card_nums) = rec.allowed_card_nums.clone() else {
219 continue;
220 };
221 let mut acc_ids = rec.raw_explicit_acc_ids.clone().unwrap_or_default();
227 for cn in &card_nums {
228 let candidates = resolver(cn);
229 match candidates.len() {
230 0 => {
231 unresolved += 1;
232 unresolved_callback(&rec.id, cn);
233 }
234 1 => {
235 acc_ids.insert(candidates[0]);
236 resolved += 1;
237 }
238 _ => {
239 ambiguous += 1;
240 ambiguous_callback(&rec.id, cn, &candidates);
241 }
242 }
243 }
244 if !card_nums.is_empty() {
272 if acc_ids.is_empty() {
273 acc_ids.insert(0u64);
275 }
276 rec.allowed_acc_ids = Some(acc_ids);
277 } else if !acc_ids.is_empty() {
278 rec.allowed_acc_ids = Some(acc_ids);
279 }
280 }
281 let file = KeysFile {
282 version: current.version,
283 keys: new_keys,
284 };
285 let hash_index = Self::build_hash_index(&file);
286 self.current.store(Arc::new(file));
287 self.hash_index.store(Arc::new(hash_index));
288 (resolved, unresolved, ambiguous)
289 }
290
291 pub fn verify(&self, plaintext: &str) -> Option<Arc<KeyRecord>> {
297 if !KeyRecord::is_generated_plaintext_shape(plaintext) {
298 return None;
299 }
300 let computed_hash = hash_plaintext(plaintext);
301 let index = self.hash_index.load();
302 let now = Utc::now();
303 let candidates = index.get(&computed_hash)?;
304 for k in candidates {
305 if k.is_expired(now) {
306 continue;
307 }
308 if let Err(e) = k.check_machine() {
309 let key_id = crate::metrics::redact_key_id_for_logs(&k.id);
310 tracing::warn!(
311 key_id = %key_id,
312 error = %e,
313 "api key matched but machine binding failed; rejecting"
314 );
315 return None;
316 }
317 return Some(Arc::new(k.clone()));
318 }
319 None
320 }
321
322 #[must_use]
327 pub fn is_configured(&self) -> bool {
328 self.path.is_some()
329 }
330
331 pub fn path(&self) -> Option<&Path> {
332 self.path.as_deref()
333 }
334
335 #[must_use]
336 pub fn len(&self) -> usize {
337 self.current.load().keys.len()
338 }
339
340 #[must_use]
341 pub fn is_empty(&self) -> bool {
342 self.len() == 0
343 }
344
345 #[must_use]
361 pub fn has_any_card_num_restrictions(&self) -> bool {
362 self.current
363 .load()
364 .keys
365 .iter()
366 .any(|k| k.allowed_card_nums.as_ref().is_some_and(|v| !v.is_empty()))
367 }
368
369 pub fn get_by_id(&self, id: &str) -> Option<Arc<KeyRecord>> {
383 let snap = self.current.load_full();
384 snap.keys
385 .iter()
386 .find(|k| k.id == id)
387 .map(|k| Arc::new(k.clone()))
388 }
389
390 pub fn get_by_id_for_current_machine(&self, id: &str) -> Option<Arc<KeyRecord>> {
409 let rec = self.get_by_id(id)?;
410 if let Err(e) = rec.check_machine() {
411 let key_id = crate::metrics::redact_key_id_for_logs(&rec.id);
412 tracing::warn!(
413 key_id = %key_id,
414 error = %e,
415 "api key get_by_id_for_current_machine: machine binding failed; \
416 treating as revoked (caller should reject as if key not found)"
417 );
418 return None;
419 }
420 Some(rec)
421 }
422
423 #[must_use]
425 pub fn ids(&self) -> Vec<String> {
426 self.current
427 .load()
428 .keys
429 .iter()
430 .map(|k| k.id.clone())
431 .collect()
432 }
433}
434
435fn with_keys_lock<F, R>(path: &Path, f: F) -> Result<R, KeyStoreError>
441where
442 F: FnOnce(&Path) -> Result<R, KeyStoreError>,
443{
444 if let Some(parent) = path.parent()
445 && !parent.as_os_str().is_empty()
446 {
447 fs::create_dir_all(parent).map_err(|source| KeyStoreError::Write {
448 path: parent.to_path_buf(),
449 source,
450 })?;
451 }
452 let _guard = AdvisoryLockGuard::acquire_exclusive(path)?;
453 f(path)
454}
455
456pub fn append_key(path: &Path, new_record: KeyRecord) -> Result<(), KeyStoreError> {
457 with_keys_lock(path, |path| {
458 let mut file = match fs::metadata(path) {
459 Ok(_) => KeyStore::load_file_unlocked(path)?,
460 Err(_) => KeysFile {
461 version: CURRENT_VERSION,
462 keys: vec![],
463 },
464 };
465 if file.keys.iter().any(|k| k.id == new_record.id) {
466 return Err(KeyStoreError::DuplicateId(new_record.id));
467 }
468 file.keys.push(new_record);
469 write_atomic_inner(path, &file)
470 })
471}
472
473pub fn list_keys(path: &Path) -> Result<Vec<KeyRecord>, KeyStoreError> {
475 let file = KeyStore::load_file(path)?;
476 Ok(file.keys)
477}
478
479pub fn update_key<F>(path: &Path, id: &str, mutate: F) -> Result<bool, KeyStoreError>
484where
485 F: FnOnce(&mut KeyRecord) -> bool,
486{
487 with_keys_lock(path, |path| {
489 let mut file = KeyStore::load_file_unlocked(path)?;
490 let Some(rec) = file.keys.iter_mut().find(|k| k.id == id) else {
491 return Ok(false);
492 };
493 let changed = mutate(rec);
494 if changed {
495 write_atomic_inner(path, &file)?;
496 }
497 Ok(changed)
498 })
499}
500
501pub fn remove_key(path: &Path, id: &str) -> Result<bool, KeyStoreError> {
503 with_keys_lock(path, |path| {
505 let mut file = KeyStore::load_file_unlocked(path)?;
506 let before = file.keys.len();
507 file.keys.retain(|k| k.id != id);
508 let removed = before != file.keys.len();
509 if removed {
510 write_atomic_inner(path, &file)?;
511 }
512 Ok(removed)
513 })
514}
515
516fn write_atomic_inner(path: &Path, file: &KeysFile) -> Result<(), KeyStoreError> {
538 let text = serde_json::to_string_pretty(file)?;
539
540 let nanos = keystore_tempfile_nanos_or_zero();
542 let tmp_name = match path.file_name().and_then(|n| n.to_str()) {
543 Some(name) => format!(
544 "{name}.{pid}.{nanos}.tmp",
545 pid = std::process::id(),
546 nanos = nanos,
547 ),
548 None => format!(
549 "keys.{pid}.{nanos}.tmp",
550 pid = std::process::id(),
551 nanos = nanos,
552 ),
553 };
554 let tmp = path
555 .parent()
556 .map(|p| p.join(&tmp_name))
557 .unwrap_or_else(|| Path::new(&tmp_name).to_path_buf());
558
559 use std::io::Write;
561 #[cfg(unix)]
562 let mut f = {
563 use std::os::unix::fs::OpenOptionsExt;
564 fs::OpenOptions::new()
565 .create_new(true)
566 .write(true)
567 .mode(0o600)
568 .open(&tmp)
569 .map_err(|source| KeyStoreError::Write {
570 path: tmp.clone(),
571 source,
572 })?
573 };
574 #[cfg(not(unix))]
575 let mut f = fs::OpenOptions::new()
576 .create_new(true)
577 .write(true)
578 .open(&tmp)
579 .map_err(|source| KeyStoreError::Write {
580 path: tmp.clone(),
581 source,
582 })?;
583
584 let write_res = f
585 .write_all(text.as_bytes())
586 .and_then(|_| f.sync_all())
587 .map_err(|source| KeyStoreError::Write {
588 path: tmp.clone(),
589 source,
590 });
591 drop(f);
592
593 if let Err(e) = write_res {
594 if let Err(cleanup_err) = fs::remove_file(&tmp) {
595 tracing::debug!(
596 path = %tmp.display(),
597 error = %cleanup_err,
598 "keystore atomic write failed; tempfile cleanup also failed"
599 );
600 }
601 return Err(e);
602 }
603
604 #[cfg(unix)]
606 {
607 use std::os::unix::fs::PermissionsExt;
608 if let Err(err) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)) {
609 tracing::warn!(
610 path = %tmp.display(),
611 error = %err,
612 "keystore tempfile chmod 0600 failed"
613 );
614 }
615 }
616
617 fs::rename(&tmp, path).map_err(|source| KeyStoreError::Write {
619 path: path.to_path_buf(),
620 source,
621 })?;
622
623 Ok(())
624}
625
626fn keystore_tempfile_nanos_or_zero() -> u128 {
627 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
628 Ok(duration) => duration.as_nanos(),
629 Err(err) => {
630 tracing::warn!(
631 error = ?err,
632 "keystore wall clock is before UNIX_EPOCH; falling back to zero tempfile timestamp"
633 );
634 0
635 }
636 }
637}
638
639fn lock_path_for(path: &Path) -> PathBuf {
652 path.parent()
653 .map(|p| {
654 let mut name = path
655 .file_name()
656 .and_then(|n| n.to_str())
657 .unwrap_or("keys")
658 .to_string();
659 name.push_str(".lock");
660 p.join(name)
661 })
662 .unwrap_or_else(|| PathBuf::from("keys.lock"))
663}
664
665#[cfg(unix)]
666struct AdvisoryLockGuard {
667 fd: std::os::fd::OwnedFd,
668}
669
670#[cfg(unix)]
671impl AdvisoryLockGuard {
672 fn acquire_exclusive(path: &Path) -> Result<Self, KeyStoreError> {
673 Self::acquire(path, libc::LOCK_EX)
674 }
675
676 fn acquire_shared(path: &Path) -> Result<Self, KeyStoreError> {
677 Self::acquire(path, libc::LOCK_SH)
678 }
679
680 fn acquire(path: &Path, operation: i32) -> Result<Self, KeyStoreError> {
681 use std::os::fd::AsRawFd;
682 use std::os::unix::fs::OpenOptionsExt;
683
684 let lock_path = lock_path_for(path);
687
688 let file = fs::OpenOptions::new()
689 .create(true)
690 .read(true)
691 .write(true)
692 .truncate(false)
693 .mode(0o600)
694 .open(&lock_path)
695 .map_err(|source| KeyStoreError::Write {
696 path: lock_path.clone(),
697 source,
698 })?;
699 let raw = file.as_raw_fd();
700 let rc = unsafe { libc::flock(raw, operation) };
703 if rc != 0 {
704 return Err(KeyStoreError::Write {
705 path: lock_path,
706 source: std::io::Error::last_os_error(),
707 });
708 }
709 let owned: std::os::fd::OwnedFd = file.into();
710 Ok(Self { fd: owned })
711 }
712}
713
714#[cfg(unix)]
715impl Drop for AdvisoryLockGuard {
716 fn drop(&mut self) {
717 use std::os::fd::AsRawFd;
718 let rc = unsafe { libc::flock(self.fd.as_raw_fd(), libc::LOCK_UN) };
723 if rc != 0 {
724 eprintln!(
725 "futu-auth warning: keystore advisory lock unlock failed: {}",
726 std::io::Error::last_os_error()
727 );
728 }
729 }
730}
731
732#[cfg(windows)]
733struct AdvisoryLockGuard {
734 file: fs::File,
735 overlapped: windows_sys::Win32::System::IO::OVERLAPPED,
736 lock_path: PathBuf,
737}
738
739#[cfg(windows)]
740impl AdvisoryLockGuard {
741 fn acquire_exclusive(path: &Path) -> Result<Self, KeyStoreError> {
742 use windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK;
743
744 Self::acquire(path, LOCKFILE_EXCLUSIVE_LOCK)
745 }
746
747 fn acquire_shared(path: &Path) -> Result<Self, KeyStoreError> {
748 Self::acquire(path, 0)
749 }
750
751 fn acquire(path: &Path, flags: u32) -> Result<Self, KeyStoreError> {
752 use std::os::windows::io::AsRawHandle;
753 use windows_sys::Win32::Storage::FileSystem::LockFileEx;
754 use windows_sys::Win32::System::IO::OVERLAPPED;
755
756 let lock_path = lock_path_for(path);
757 let file = fs::OpenOptions::new()
758 .create(true)
759 .read(true)
760 .write(true)
761 .truncate(false)
762 .open(&lock_path)
763 .map_err(|source| KeyStoreError::Write {
764 path: lock_path.clone(),
765 source,
766 })?;
767
768 let mut overlapped = OVERLAPPED::default();
769 let raw = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
770 let ok = unsafe { LockFileEx(raw, flags, 0, u32::MAX, u32::MAX, &mut overlapped) };
775 if ok == 0 {
776 return Err(KeyStoreError::Write {
777 path: lock_path,
778 source: std::io::Error::last_os_error(),
779 });
780 }
781
782 Ok(Self {
783 file,
784 overlapped,
785 lock_path,
786 })
787 }
788}
789
790#[cfg(windows)]
791impl Drop for AdvisoryLockGuard {
792 fn drop(&mut self) {
793 use std::os::windows::io::AsRawHandle;
794 use windows_sys::Win32::Storage::FileSystem::UnlockFileEx;
795
796 let raw = self.file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
797 let ok = unsafe { UnlockFileEx(raw, 0, u32::MAX, u32::MAX, &mut self.overlapped) };
800 if ok == 0 {
801 eprintln!(
802 "futu-auth warning: keystore advisory lock unlock failed for {}: {}",
803 self.lock_path.display(),
804 std::io::Error::last_os_error()
805 );
806 }
807 }
808}
809
810#[cfg(all(not(unix), not(windows)))]
811struct AdvisoryLockGuard;
812
813#[cfg(all(not(unix), not(windows)))]
814impl AdvisoryLockGuard {
815 fn acquire_exclusive(_path: &Path) -> Result<Self, KeyStoreError> {
816 Ok(Self)
819 }
820
821 fn acquire_shared(_path: &Path) -> Result<Self, KeyStoreError> {
822 Ok(Self)
825 }
826}
827
828#[cfg(test)]
829mod tests;