1use std::collections::HashMap;
2use std::io::Write;
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5
6use aes_gcm::aead::{Aead, KeyInit, OsRng};
7use aes_gcm::{Aes256Gcm, Nonce};
8use argon2::Argon2;
9use chrono::Utc;
10use rand::RngCore;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use zeroize::Zeroizing;
14
15use crate::error::{RoboticusError, Result};
16
17const SALT_LEN: usize = 16;
18const NONCE_LEN: usize = 12;
19
20fn lock_or_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
29 m.lock().unwrap_or_else(|e| e.into_inner())
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct KeystoreData {
34 entries: HashMap<String, String>,
35}
36
37struct KeystoreState {
40 entries: Option<HashMap<String, Zeroizing<String>>>,
41 passphrase: Option<Zeroizing<String>>,
42 last_file_fingerprint: Option<(std::time::SystemTime, u64)>,
43}
44
45#[derive(Clone)]
46pub struct Keystore {
47 path: PathBuf,
48 state: Arc<Mutex<KeystoreState>>,
49}
50
51impl Keystore {
52 pub fn new(path: impl Into<PathBuf>) -> Self {
53 Self {
54 path: path.into(),
55 state: Arc::new(Mutex::new(KeystoreState {
56 entries: None,
57 passphrase: None,
58 last_file_fingerprint: None,
59 })),
60 }
61 }
62
63 pub fn default_path() -> PathBuf {
64 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
65 PathBuf::from(home).join(".roboticus").join("keystore.enc")
66 }
67
68 pub fn unlock(&self, passphrase: &str) -> Result<()> {
69 if !self.path.exists() {
70 let mut st = lock_or_recover(&self.state);
71 st.entries = Some(HashMap::new());
72 st.passphrase = Some(Zeroizing::new(passphrase.to_string()));
73 st.last_file_fingerprint = None;
74 drop(st);
75 self.save()?;
76 self.append_audit_event(
77 "initialize",
78 None,
79 json!({
80 "result": "ok",
81 "details": "created new keystore file"
82 }),
83 )?;
84 return Ok(());
85 }
86 let zeroized_entries = self.decrypt_entries(passphrase)?;
87 let mut st = lock_or_recover(&self.state);
88 st.entries = Some(zeroized_entries);
89 st.passphrase = Some(Zeroizing::new(passphrase.to_string()));
90 st.last_file_fingerprint = self.current_file_fingerprint();
91 Ok(())
92 }
93
94 pub fn unlock_machine(&self) -> Result<()> {
101 self.unlock(&machine_passphrase())
102 }
103
104 pub fn is_unlocked(&self) -> bool {
105 lock_or_recover(&self.state).entries.is_some()
106 }
107
108 pub fn get(&self, key: &str) -> Option<String> {
109 let mut st = lock_or_recover(&self.state);
110 if let Err(e) = self.refresh_locked(&mut st) {
111 tracing::warn!(error = %e, "keystore refresh failed, using cached entries");
112 }
113 st.entries
114 .as_ref()
115 .and_then(|m| m.get(key).map(|v| (**v).clone()))
116 }
117
118 pub fn set(&self, key: &str, value: &str) -> Result<()> {
119 let previous = {
120 let mut st = lock_or_recover(&self.state);
121 let entries = st
122 .entries
123 .as_mut()
124 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
125 entries.insert(key.to_string(), Zeroizing::new(value.to_string()))
126 };
127 let save_res = self.save();
128 let rolled_back = save_res.is_err();
129 if rolled_back {
130 let mut st = lock_or_recover(&self.state);
131 if let Some(entries) = st.entries.as_mut() {
132 if let Some(prev) = previous {
133 entries.insert(key.to_string(), prev);
134 } else {
135 entries.remove(key);
136 }
137 }
138 }
139 let audit_res = self.append_audit_event(
140 "set",
141 Some(key),
142 json!({
143 "result": if save_res.is_ok() { "ok" } else { "error" },
144 "rolled_back": rolled_back
145 }),
146 );
147 match (save_res, audit_res) {
148 (Err(e), _) => Err(e),
149 (Ok(()), Err(e)) => Err(e),
150 (Ok(()), Ok(())) => Ok(()),
151 }
152 }
153
154 pub fn remove(&self, key: &str) -> Result<bool> {
155 let removed = {
156 let mut st = lock_or_recover(&self.state);
157 let entries = st
158 .entries
159 .as_mut()
160 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
161 entries.remove(key)
162 };
163 let existed = removed.is_some();
164 if existed {
165 let save_res = self.save();
166 let rolled_back = save_res.is_err();
167 if rolled_back {
168 let mut st = lock_or_recover(&self.state);
169 if let Some(entries) = st.entries.as_mut()
170 && let Some(prev) = removed
171 {
172 entries.insert(key.to_string(), prev);
173 }
174 }
175 let audit_res = self.append_audit_event(
176 "remove",
177 Some(key),
178 json!({
179 "result": if save_res.is_ok() { "ok" } else { "error" },
180 "rolled_back": rolled_back
181 }),
182 );
183 match (save_res, audit_res) {
184 (Err(e), _) => return Err(e),
185 (Ok(()), Err(e)) => return Err(e),
186 (Ok(()), Ok(())) => {}
187 }
188 }
189 Ok(existed)
190 }
191
192 pub fn list_keys(&self) -> Vec<String> {
193 let mut st = lock_or_recover(&self.state);
194 if let Err(e) = self.refresh_locked(&mut st) {
195 tracing::warn!(error = %e, "keystore refresh failed, using cached entries");
196 }
197 st.entries
198 .as_ref()
199 .map(|m| m.keys().cloned().collect())
200 .unwrap_or_default()
201 }
202
203 pub fn import(&self, new_entries: HashMap<String, String>) -> Result<usize> {
204 let count = new_entries.len();
205 let snapshot = {
206 let mut st = lock_or_recover(&self.state);
207 let entries = st
208 .entries
209 .as_mut()
210 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
211 let before = entries.clone();
212 entries.extend(new_entries.into_iter().map(|(k, v)| (k, Zeroizing::new(v))));
213 before
214 };
215 let save_res = self.save();
216 let rolled_back = save_res.is_err();
217 if rolled_back {
218 let mut st = lock_or_recover(&self.state);
219 st.entries = Some(snapshot);
220 }
221 let audit_res = self.append_audit_event(
222 "import",
223 None,
224 json!({
225 "result": if save_res.is_ok() { "ok" } else { "error" },
226 "count": count,
227 "rolled_back": rolled_back
228 }),
229 );
230 match (save_res, audit_res) {
231 (Err(e), _) => return Err(e),
232 (Ok(()), Err(e)) => return Err(e),
233 (Ok(()), Ok(())) => {}
234 }
235 Ok(count)
236 }
237
238 pub fn lock(&self) {
239 let mut st = lock_or_recover(&self.state);
240 st.entries = None;
241 st.passphrase = None;
242 }
243
244 pub fn rekey(&self, new_passphrase: &str) -> Result<()> {
246 if !self.is_unlocked() {
247 return Err(RoboticusError::Keystore("keystore is locked".into()));
248 }
249 let old_passphrase = {
250 let mut st = lock_or_recover(&self.state);
251 let prev = st.passphrase.clone();
252 st.passphrase = Some(Zeroizing::new(new_passphrase.to_string()));
253 prev
254 };
255 let save_res = self.save();
256 let rolled_back = save_res.is_err();
257 if rolled_back {
258 let mut st = lock_or_recover(&self.state);
259 st.passphrase = old_passphrase;
260 }
261 let audit_res = self.append_audit_event(
262 "rekey",
263 None,
264 json!({
265 "result": if save_res.is_ok() { "ok" } else { "error" },
266 "rolled_back": rolled_back
267 }),
268 );
269 match (save_res, audit_res) {
270 (Err(e), _) => Err(e),
271 (Ok(()), Err(e)) => Err(e),
272 (Ok(()), Ok(())) => Ok(()),
273 }
274 }
275
276 fn audit_log_path(&self) -> PathBuf {
277 self.path.with_extension("audit.log")
278 }
279
280 fn append_audit_event(
281 &self,
282 operation: &str,
283 key: Option<&str>,
284 metadata: serde_json::Value,
285 ) -> Result<()> {
286 let audit_path = self.audit_log_path();
287 if let Some(parent) = audit_path.parent() {
288 std::fs::create_dir_all(parent)?;
289 }
290 let mut file = std::fs::OpenOptions::new()
291 .create(true)
292 .append(true)
293 .open(&audit_path)?;
294 #[cfg(unix)]
295 if let Ok(meta) = file.metadata() {
296 use std::os::unix::fs::PermissionsExt;
297 if meta.permissions().mode() & 0o777 != 0o600
298 && let Err(e) =
299 std::fs::set_permissions(&audit_path, std::fs::Permissions::from_mode(0o600))
300 {
301 tracing::warn!(error = %e, path = %audit_path.display(), "failed to set keystore audit log permissions");
302 }
303 }
304
305 let redacted_key = key.map(redact_key_name);
306 let record = json!({
307 "timestamp": Utc::now().to_rfc3339(),
308 "operation": operation,
309 "key": redacted_key,
310 "pid": std::process::id(),
311 "process": std::env::args().next().unwrap_or_else(|| "unknown".to_string()),
312 "keystore_path": self.path,
313 "metadata": metadata
314 });
315 file.write_all(record.to_string().as_bytes())?;
316 file.write_all(b"\n")?;
317 file.flush()?;
318 Ok(())
319 }
320
321 fn save(&self) -> Result<()> {
322 let st = lock_or_recover(&self.state);
323 let entries = st
324 .entries
325 .as_ref()
326 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
327
328 let passphrase = st
329 .passphrase
330 .as_ref()
331 .ok_or_else(|| RoboticusError::Keystore("no passphrase available".into()))?;
332
333 let salt = fresh_salt();
334 let key = derive_key(passphrase, &salt)?;
335
336 let store = KeystoreData {
337 entries: entries
338 .iter()
339 .map(|(k, v)| (k.clone(), (**v).clone()))
340 .collect(),
341 };
342 let plaintext = serde_json::to_vec(&store)?;
343
344 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
345 .map_err(|e| RoboticusError::Keystore(e.to_string()))?;
346
347 let mut nonce_bytes = [0u8; NONCE_LEN];
348 OsRng.fill_bytes(&mut nonce_bytes);
349 let nonce = Nonce::from_slice(&nonce_bytes);
350
351 let ciphertext = cipher
352 .encrypt(nonce, plaintext.as_ref())
353 .map_err(|e| RoboticusError::Keystore(format!("encryption failed: {e}")))?;
354
355 let mut out = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
356 out.extend_from_slice(&salt);
357 out.extend_from_slice(&nonce_bytes);
358 out.extend_from_slice(&ciphertext);
359
360 drop(st);
362
363 if let Some(parent) = self.path.parent() {
364 std::fs::create_dir_all(parent)?;
365 }
366
367 let tmp = self.path.with_extension("tmp");
368 std::fs::write(&tmp, &out)?;
369
370 #[cfg(unix)]
371 {
372 use std::os::unix::fs::PermissionsExt;
373 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
374 }
375
376 std::fs::rename(&tmp, &self.path)?;
377 let fingerprint = self.current_file_fingerprint();
378 let mut st = lock_or_recover(&self.state);
379 st.last_file_fingerprint = fingerprint;
380
381 Ok(())
382 }
383
384 fn decrypt_entries(&self, passphrase: &str) -> Result<HashMap<String, Zeroizing<String>>> {
385 let data = std::fs::read(&self.path)?;
386 if data.len() < SALT_LEN + NONCE_LEN + 1 {
387 return Err(RoboticusError::Keystore("corrupt keystore file".into()));
388 }
389
390 let salt = &data[..SALT_LEN];
391 let nonce_bytes = &data[SALT_LEN..SALT_LEN + NONCE_LEN];
392 let ciphertext = &data[SALT_LEN + NONCE_LEN..];
393
394 let key = derive_key(passphrase, salt)?;
395 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
396 .map_err(|e| RoboticusError::Keystore(e.to_string()))?;
397 let nonce = Nonce::from_slice(nonce_bytes);
398
399 let plaintext = cipher
400 .decrypt(nonce, ciphertext)
401 .map_err(|_| RoboticusError::Keystore("decryption failed (wrong passphrase?)".into()))?;
402
403 let store: KeystoreData = serde_json::from_slice(&plaintext)
404 .map_err(|e| RoboticusError::Keystore(format!("corrupt keystore data: {e}")))?;
405
406 Ok(store
407 .entries
408 .into_iter()
409 .map(|(k, v)| (k, Zeroizing::new(v)))
410 .collect())
411 }
412
413 fn refresh_locked(&self, st: &mut KeystoreState) -> Result<()> {
414 if st.entries.is_none() {
415 return Ok(());
416 }
417 let Some(passphrase) = st.passphrase.as_ref() else {
418 return Ok(());
419 };
420 if !self.path.exists() {
421 return Ok(());
422 }
423 let current_fingerprint = self.current_file_fingerprint();
424 if current_fingerprint.is_some() && st.last_file_fingerprint == current_fingerprint {
425 return Ok(());
426 }
427 let refreshed = self.decrypt_entries(passphrase)?;
428 st.entries = Some(refreshed);
429 st.last_file_fingerprint = current_fingerprint;
430 Ok(())
431 }
432
433 fn current_file_fingerprint(&self) -> Option<(std::time::SystemTime, u64)> {
434 let meta = std::fs::metadata(&self.path).ok()?;
435 let modified = meta.modified().ok()?;
436 Some((modified, meta.len()))
437 }
438}
439
440fn derive_key(passphrase: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
441 let params = argon2::Params::new(65536, 3, 1, Some(32))
442 .map_err(|e| RoboticusError::Keystore(format!("argon2 params: {e}")))?;
443 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
444
445 let mut key = Zeroizing::new([0u8; 32]);
446 argon2
447 .hash_password_into(passphrase.as_bytes(), salt, key.as_mut())
448 .map_err(|e| RoboticusError::Keystore(format!("key derivation failed: {e}")))?;
449 Ok(key)
450}
451
452fn fresh_salt() -> [u8; SALT_LEN] {
453 let mut salt = [0u8; SALT_LEN];
454 OsRng.fill_bytes(&mut salt);
455 salt
456}
457
458fn redact_key_name(key: &str) -> String {
461 let visible: String = key.chars().take(3).collect();
462 format!("{visible}***")
463}
464
465fn machine_passphrase() -> String {
473 let hostname = std::env::var("HOSTNAME")
474 .or_else(|_| std::env::var("HOST"))
475 .unwrap_or_else(|_| "unknown-host".into());
476 let username = std::env::var("USER")
477 .or_else(|_| std::env::var("USERNAME"))
478 .unwrap_or_else(|_| "unknown-user".into());
479 format!("roboticus-machine-key:{hostname}:{username}")
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use tempfile::NamedTempFile;
486
487 fn temp_path() -> PathBuf {
488 let f = NamedTempFile::new().unwrap();
489 let p = f.path().to_path_buf();
490 drop(f);
491 p
492 }
493
494 #[test]
495 fn test_new_keystore_creates_empty() {
496 let path = temp_path();
497 let ks = Keystore::new(&path);
498 assert!(!ks.is_unlocked());
499
500 ks.unlock("test-pass").unwrap();
501 assert!(ks.is_unlocked());
502 assert!(ks.list_keys().is_empty());
503 assert!(path.exists());
504 }
505
506 #[test]
507 fn test_set_and_get() {
508 let path = temp_path();
509 let ks = Keystore::new(&path);
510 ks.unlock("pass").unwrap();
511
512 ks.set("api_key", "sk-123").unwrap();
513 assert_eq!(ks.get("api_key"), Some("sk-123".into()));
514 assert_eq!(ks.get("missing"), None);
515 }
516
517 #[test]
518 fn test_persistence() {
519 let path = temp_path();
520
521 {
522 let ks = Keystore::new(&path);
523 ks.unlock("my-pass").unwrap();
524 ks.set("secret", "value42").unwrap();
525 }
526
527 {
528 let ks = Keystore::new(&path);
529 assert!(!ks.is_unlocked());
530 ks.unlock("my-pass").unwrap();
531 assert_eq!(ks.get("secret"), Some("value42".into()));
532 }
533 }
534
535 #[test]
536 fn test_wrong_passphrase() {
537 let path = temp_path();
538 let ks = Keystore::new(&path);
539 ks.unlock("correct").unwrap();
540 ks.set("key", "val").unwrap();
541 drop(ks);
542
543 let ks2 = Keystore::new(&path);
544 let result = ks2.unlock("wrong");
545 assert!(result.is_err());
546 assert!(result.unwrap_err().to_string().contains("decryption"));
547 }
548
549 #[test]
550 fn test_list_keys() {
551 let path = temp_path();
552 let ks = Keystore::new(&path);
553 ks.unlock("pass").unwrap();
554
555 ks.set("alpha", "1").unwrap();
556 ks.set("beta", "2").unwrap();
557 ks.set("gamma", "3").unwrap();
558
559 let mut keys = ks.list_keys();
560 keys.sort();
561 assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
562 }
563
564 #[test]
565 fn test_remove() {
566 let path = temp_path();
567 let ks = Keystore::new(&path);
568 ks.unlock("pass").unwrap();
569
570 ks.set("keep", "a").unwrap();
571 ks.set("discard", "b").unwrap();
572
573 assert!(ks.remove("discard").unwrap());
574 assert!(!ks.remove("discard").unwrap());
575 assert_eq!(ks.get("discard"), None);
576 assert_eq!(ks.get("keep"), Some("a".into()));
577
578 drop(ks);
579 let ks2 = Keystore::new(&path);
580 ks2.unlock("pass").unwrap();
581 assert_eq!(ks2.get("discard"), None);
582 assert_eq!(ks2.get("keep"), Some("a".into()));
583 }
584
585 #[test]
586 fn test_import() {
587 let path = temp_path();
588 let ks = Keystore::new(&path);
589 ks.unlock("pass").unwrap();
590
591 let mut batch = HashMap::new();
592 batch.insert("k1".into(), "v1".into());
593 batch.insert("k2".into(), "v2".into());
594 batch.insert("k3".into(), "v3".into());
595
596 let count = ks.import(batch).unwrap();
597 assert_eq!(count, 3);
598 assert_eq!(ks.get("k1"), Some("v1".into()));
599 assert_eq!(ks.get("k2"), Some("v2".into()));
600 assert_eq!(ks.get("k3"), Some("v3".into()));
601 }
602
603 #[test]
604 fn test_machine_key() {
605 let path = temp_path();
606 let ks = Keystore::new(&path);
607 ks.unlock_machine().unwrap();
608 ks.set("service_key", "abc").unwrap();
609 drop(ks);
610
611 let ks2 = Keystore::new(&path);
612 ks2.unlock_machine().unwrap();
613 assert_eq!(ks2.get("service_key"), Some("abc".into()));
614 }
615
616 #[test]
617 fn test_get_refreshes_entries_after_external_write() {
618 let path = temp_path();
619 let ks_a = Keystore::new(&path);
620 ks_a.unlock_machine().unwrap();
621 ks_a.set("openai_api_key", "old-value").unwrap();
622 assert_eq!(ks_a.get("openai_api_key"), Some("old-value".into()));
623
624 let ks_b = Keystore::new(&path);
626 ks_b.unlock_machine().unwrap();
627 ks_b.set("openai_api_key", "new-value").unwrap();
628
629 assert_eq!(ks_a.get("openai_api_key"), Some("new-value".into()));
631 }
632
633 #[test]
634 fn test_lock_clears_memory() {
635 let path = temp_path();
636 let ks = Keystore::new(&path);
637 ks.unlock("pass").unwrap();
638 ks.set("secret", "hidden").unwrap();
639 assert!(ks.is_unlocked());
640
641 ks.lock();
642
643 assert!(!ks.is_unlocked());
644 assert_eq!(ks.get("secret"), None);
645 assert!(ks.list_keys().is_empty());
646 }
647
648 #[test]
649 fn test_rekey() {
650 let path = temp_path();
651 let ks = Keystore::new(&path);
652 ks.unlock("old-pass").unwrap();
653 ks.set("data", "preserved").unwrap();
654
655 ks.rekey("new-pass").unwrap();
656 drop(ks);
657
658 let ks2 = Keystore::new(&path);
659 assert!(ks2.unlock("old-pass").is_err());
660 ks2.unlock("new-pass").unwrap();
661 assert_eq!(ks2.get("data"), Some("preserved".into()));
662 }
663
664 #[test]
665 fn test_keystore_mutations_are_audited() {
666 let path = temp_path();
667 let ks = Keystore::new(&path);
668 ks.unlock("pass").unwrap();
669 ks.set("telegram_bot_token", "secret").unwrap();
670 assert!(ks.remove("telegram_bot_token").unwrap());
671 ks.rekey("new-pass").unwrap();
672
673 let audit_path = path.with_extension("audit.log");
674 let audit = std::fs::read_to_string(audit_path).unwrap();
675 assert!(audit.contains("\"operation\":\"initialize\""));
676 assert!(audit.contains("\"operation\":\"set\""));
677 assert!(audit.contains("\"operation\":\"remove\""));
678 assert!(audit.contains("\"operation\":\"rekey\""));
679 assert!(audit.contains("\"key\":\"tel***\""));
681 assert!(!audit.contains("telegram_bot_token"));
682 assert!(!audit.contains("secret"));
683 }
684
685 #[test]
686 fn test_default_path() {
687 let path = Keystore::default_path();
688 assert!(path.to_str().unwrap().contains("keystore.enc"));
689 assert!(path.to_str().unwrap().contains(".roboticus"));
690 }
691
692 #[test]
693 fn test_set_on_locked_keystore_fails() {
694 let path = temp_path();
695 let ks = Keystore::new(&path);
696 let result = ks.set("key", "value");
698 assert!(result.is_err());
699 assert!(result.unwrap_err().to_string().contains("locked"));
700 }
701
702 #[test]
703 fn test_remove_on_locked_keystore_fails() {
704 let path = temp_path();
705 let ks = Keystore::new(&path);
706 let result = ks.remove("key");
707 assert!(result.is_err());
708 assert!(result.unwrap_err().to_string().contains("locked"));
709 }
710
711 #[test]
712 fn test_import_on_locked_keystore_fails() {
713 let path = temp_path();
714 let ks = Keystore::new(&path);
715 let result = ks.import(HashMap::new());
716 assert!(result.is_err());
717 assert!(result.unwrap_err().to_string().contains("locked"));
718 }
719
720 #[test]
721 fn test_rekey_on_locked_keystore_fails() {
722 let path = temp_path();
723 let ks = Keystore::new(&path);
724 let result = ks.rekey("new-pass");
725 assert!(result.is_err());
726 assert!(result.unwrap_err().to_string().contains("locked"));
727 }
728
729 #[test]
730 fn test_get_on_locked_keystore_returns_none() {
731 let path = temp_path();
732 let ks = Keystore::new(&path);
733 assert_eq!(ks.get("anything"), None);
734 }
735
736 #[test]
737 fn test_list_keys_on_locked_keystore_returns_empty() {
738 let path = temp_path();
739 let ks = Keystore::new(&path);
740 assert!(ks.list_keys().is_empty());
741 }
742
743 #[test]
744 fn test_corrupt_keystore_file() {
745 let path = temp_path();
746 std::fs::write(&path, b"short").unwrap();
748 let ks = Keystore::new(&path);
749 let result = ks.unlock("pass");
750 assert!(result.is_err());
751 assert!(result.unwrap_err().to_string().contains("corrupt"));
752 }
753
754 #[test]
755 fn test_set_overwrites_existing_key() {
756 let path = temp_path();
757 let ks = Keystore::new(&path);
758 ks.unlock("pass").unwrap();
759
760 ks.set("key", "first").unwrap();
761 assert_eq!(ks.get("key"), Some("first".into()));
762
763 ks.set("key", "second").unwrap();
764 assert_eq!(ks.get("key"), Some("second".into()));
765 }
766
767 #[cfg(unix)]
768 #[test]
769 fn test_set_rolls_back_on_save_failure() {
770 use std::os::unix::fs::PermissionsExt;
771 let dir = tempfile::tempdir().unwrap();
772 let path = dir.path().join("keystore.enc");
773 let ks = Keystore::new(&path);
774 ks.unlock("pass").unwrap();
775 ks.set("stable", "1").unwrap();
776
777 let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
778 perms.set_mode(0o500);
779 std::fs::set_permissions(dir.path(), perms).unwrap();
780
781 let res = ks.set("transient", "2");
782 assert!(res.is_err());
783 assert_eq!(ks.get("stable"), Some("1".into()));
784 assert_eq!(ks.get("transient"), None);
785 let audit = std::fs::read_to_string(path.with_extension("audit.log")).unwrap();
786 assert!(audit.contains("\"operation\":\"set\""));
787 assert!(audit.contains("\"rolled_back\":true"));
788
789 let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
790 restore.set_mode(0o700);
791 std::fs::set_permissions(dir.path(), restore).unwrap();
792 }
793
794 #[test]
795 fn test_import_audit_entry() {
796 let path = temp_path();
797 let ks = Keystore::new(&path);
798 ks.unlock("pass").unwrap();
799
800 let mut batch = HashMap::new();
801 batch.insert("imported_key".into(), "imported_value".into());
802 ks.import(batch).unwrap();
803
804 let audit_path = path.with_extension("audit.log");
805 let audit = std::fs::read_to_string(audit_path).unwrap();
806 assert!(audit.contains("\"operation\":\"import\""));
807 }
808
809 #[test]
810 fn redact_key_name_short_keys() {
811 assert_eq!(redact_key_name("ab"), "ab***");
812 assert_eq!(redact_key_name("a"), "a***");
813 assert_eq!(redact_key_name(""), "***");
814 }
815
816 #[test]
817 fn redact_key_name_long_keys() {
818 assert_eq!(redact_key_name("telegram_bot_token"), "tel***");
819 assert_eq!(redact_key_name("abc"), "abc***");
820 }
821
822 #[test]
823 fn machine_passphrase_is_deterministic() {
824 let p1 = machine_passphrase();
825 let p2 = machine_passphrase();
826 assert_eq!(p1, p2);
827 assert!(p1.starts_with("roboticus-machine-key:"));
828 }
829
830 #[test]
831 fn lock_or_recover_works_on_clean_mutex() {
832 let m = Mutex::new(42);
833 let guard = lock_or_recover(&m);
834 assert_eq!(*guard, 42);
835 }
836
837 #[test]
838 fn audit_log_path_derives_from_keystore_path() {
839 let ks = Keystore::new("/tmp/test.enc");
840 assert_eq!(ks.audit_log_path(), PathBuf::from("/tmp/test.audit.log"));
841 }
842
843 #[test]
844 fn concurrent_set_and_rekey_no_deadlock() {
845 let path = temp_path();
846 let ks = Keystore::new(&path);
847 ks.unlock("pass").unwrap();
848
849 std::thread::scope(|s| {
850 let ks1 = ks.clone();
851 let ks2 = ks.clone();
852
853 let h1 = s.spawn(move || {
854 for i in 0..50 {
855 ks1.set(&format!("key-{i}"), &format!("val-{i}")).unwrap();
856 }
857 });
858 let h2 = s.spawn(move || {
859 for _ in 0..50 {
860 ks2.rekey("pass").unwrap();
861 }
862 });
863
864 h1.join().unwrap();
867 h2.join().unwrap();
868 });
869 }
870}