1use 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#[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#[derive(Debug)]
49pub struct KeyStore {
50 path: Option<PathBuf>,
51 current: ArcSwap<KeysFile>,
52}
53
54impl KeyStore {
55 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 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 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 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 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 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 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 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
180pub 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
196pub fn list_keys(path: &Path) -> Result<Vec<KeyRecord>, KeyStoreError> {
198 let file = KeyStore::load_file(path)?;
199 Ok(file.keys)
200}
201
202pub 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
221pub 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()); let list2 = list_keys(&p).unwrap();
364 assert_eq!(list2.len(), 1);
365 assert_eq!(list2[0].id, "beta");
366
367 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 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 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![]), );
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, };
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()); store.reload().unwrap();
459 assert!(store.verify(&pt2).is_some());
460 }
461
462 #[test]
463 fn get_by_id_reflects_reload() {
464 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 let k = store.get_by_id(&id).unwrap();
481 assert!(k.scopes.contains(&Scope::QotRead));
482 assert!(!k.scopes.contains(&Scope::AccRead));
483
484 crate::store::update_key(&p, &id, |r| {
486 r.scopes.insert(Scope::AccRead);
487 true
488 })
489 .unwrap();
490 assert!(!store
492 .get_by_id(&id)
493 .unwrap()
494 .scopes
495 .contains(&Scope::AccRead));
496 store.reload().unwrap();
498 assert!(store
499 .get_by_id(&id)
500 .unwrap()
501 .scopes
502 .contains(&Scope::AccRead));
503
504 remove_key(&p, &id).unwrap();
506 store.reload().unwrap();
507 assert!(store.get_by_id(&id).is_none());
508 }
509}