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::{Result, RoboticusError};
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 crate::home_dir().join(".roboticus").join("keystore.enc")
65 }
66
67 pub fn unlock(&self, passphrase: &str) -> Result<()> {
68 if !self.path.exists() {
69 let mut st = lock_or_recover(&self.state);
70 st.entries = Some(HashMap::new());
71 st.passphrase = Some(Zeroizing::new(passphrase.to_string()));
72 st.last_file_fingerprint = None;
73 drop(st);
74 self.save()?;
75 self.append_audit_event(
76 "initialize",
77 None,
78 json!({
79 "result": "ok",
80 "details": "created new keystore file"
81 }),
82 )?;
83 return Ok(());
84 }
85 let zeroized_entries = self.decrypt_entries(passphrase)?;
86 let mut st = lock_or_recover(&self.state);
87 st.entries = Some(zeroized_entries);
88 st.passphrase = Some(Zeroizing::new(passphrase.to_string()));
89 st.last_file_fingerprint = self.current_file_fingerprint();
90 Ok(())
91 }
92
93 pub fn unlock_machine(&self) -> Result<()> {
104 let primary = machine_passphrase();
106 if self.unlock(&primary).is_ok() {
107 return Ok(());
108 }
109
110 for legacy in legacy_passphrases() {
113 if legacy != primary && self.unlock(&legacy).is_ok() {
114 tracing::info!("keystore unlocked with legacy passphrase; migrating to machine-id");
115 if let Err(e) = self.rekey(&primary) {
118 tracing::warn!(error = %e, "failed to migrate keystore to machine-id passphrase");
119 } else {
120 tracing::info!("keystore migrated to machine-id passphrase");
121 }
122 return Ok(());
123 }
124 }
125
126 self.unlock(&primary)
128 }
129
130 pub fn is_unlocked(&self) -> bool {
131 lock_or_recover(&self.state).entries.is_some()
132 }
133
134 pub fn get(&self, key: &str) -> Option<String> {
135 let mut st = lock_or_recover(&self.state);
136 if let Err(e) = self.refresh_locked(&mut st) {
137 tracing::warn!(error = %e, "keystore refresh failed, using cached entries");
138 }
139 st.entries
140 .as_ref()
141 .and_then(|m| m.get(key).map(|v| (**v).clone()))
142 }
143
144 pub fn set(&self, key: &str, value: &str) -> Result<()> {
145 let previous = {
146 let mut st = lock_or_recover(&self.state);
147 let entries = st
148 .entries
149 .as_mut()
150 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
151 entries.insert(key.to_string(), Zeroizing::new(value.to_string()))
152 };
153 let save_res = self.save();
154 let rolled_back = save_res.is_err();
155 if rolled_back {
156 let mut st = lock_or_recover(&self.state);
157 if let Some(entries) = st.entries.as_mut() {
158 if let Some(prev) = previous {
159 entries.insert(key.to_string(), prev);
160 } else {
161 entries.remove(key);
162 }
163 }
164 }
165 let audit_res = self.append_audit_event(
166 "set",
167 Some(key),
168 json!({
169 "result": if save_res.is_ok() { "ok" } else { "error" },
170 "rolled_back": rolled_back
171 }),
172 );
173 match (save_res, audit_res) {
174 (Err(e), _) => Err(e),
175 (Ok(()), Err(e)) => Err(e),
176 (Ok(()), Ok(())) => Ok(()),
177 }
178 }
179
180 pub fn remove(&self, key: &str) -> Result<bool> {
181 let removed = {
182 let mut st = lock_or_recover(&self.state);
183 let entries = st
184 .entries
185 .as_mut()
186 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
187 entries.remove(key)
188 };
189 let existed = removed.is_some();
190 if existed {
191 let save_res = self.save();
192 let rolled_back = save_res.is_err();
193 if rolled_back {
194 let mut st = lock_or_recover(&self.state);
195 if let Some(entries) = st.entries.as_mut()
196 && let Some(prev) = removed
197 {
198 entries.insert(key.to_string(), prev);
199 }
200 }
201 let audit_res = self.append_audit_event(
202 "remove",
203 Some(key),
204 json!({
205 "result": if save_res.is_ok() { "ok" } else { "error" },
206 "rolled_back": rolled_back
207 }),
208 );
209 match (save_res, audit_res) {
210 (Err(e), _) => return Err(e),
211 (Ok(()), Err(e)) => return Err(e),
212 (Ok(()), Ok(())) => {}
213 }
214 }
215 Ok(existed)
216 }
217
218 pub fn list_keys(&self) -> Vec<String> {
219 let mut st = lock_or_recover(&self.state);
220 if let Err(e) = self.refresh_locked(&mut st) {
221 tracing::warn!(error = %e, "keystore refresh failed, using cached entries");
222 }
223 st.entries
224 .as_ref()
225 .map(|m| m.keys().cloned().collect())
226 .unwrap_or_default()
227 }
228
229 pub fn import(&self, new_entries: HashMap<String, String>) -> Result<usize> {
230 let count = new_entries.len();
231 let snapshot = {
232 let mut st = lock_or_recover(&self.state);
233 let entries = st
234 .entries
235 .as_mut()
236 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
237 let before = entries.clone();
238 entries.extend(new_entries.into_iter().map(|(k, v)| (k, Zeroizing::new(v))));
239 before
240 };
241 let save_res = self.save();
242 let rolled_back = save_res.is_err();
243 if rolled_back {
244 let mut st = lock_or_recover(&self.state);
245 st.entries = Some(snapshot);
246 }
247 let audit_res = self.append_audit_event(
248 "import",
249 None,
250 json!({
251 "result": if save_res.is_ok() { "ok" } else { "error" },
252 "count": count,
253 "rolled_back": rolled_back
254 }),
255 );
256 match (save_res, audit_res) {
257 (Err(e), _) => return Err(e),
258 (Ok(()), Err(e)) => return Err(e),
259 (Ok(()), Ok(())) => {}
260 }
261 Ok(count)
262 }
263
264 pub fn lock(&self) {
265 let mut st = lock_or_recover(&self.state);
266 st.entries = None;
267 st.passphrase = None;
268 }
269
270 pub fn rekey(&self, new_passphrase: &str) -> Result<()> {
272 if !self.is_unlocked() {
273 return Err(RoboticusError::Keystore("keystore is locked".into()));
274 }
275 let old_passphrase = {
276 let mut st = lock_or_recover(&self.state);
277 let prev = st.passphrase.clone();
278 st.passphrase = Some(Zeroizing::new(new_passphrase.to_string()));
279 prev
280 };
281 let save_res = self.save();
282 let rolled_back = save_res.is_err();
283 if rolled_back {
284 let mut st = lock_or_recover(&self.state);
285 st.passphrase = old_passphrase;
286 }
287 let audit_res = self.append_audit_event(
288 "rekey",
289 None,
290 json!({
291 "result": if save_res.is_ok() { "ok" } else { "error" },
292 "rolled_back": rolled_back
293 }),
294 );
295 match (save_res, audit_res) {
296 (Err(e), _) => Err(e),
297 (Ok(()), Err(e)) => Err(e),
298 (Ok(()), Ok(())) => Ok(()),
299 }
300 }
301
302 fn audit_log_path(&self) -> PathBuf {
303 self.path.with_extension("audit.log")
304 }
305
306 fn append_audit_event(
307 &self,
308 operation: &str,
309 key: Option<&str>,
310 metadata: serde_json::Value,
311 ) -> Result<()> {
312 let audit_path = self.audit_log_path();
313 if let Some(parent) = audit_path.parent() {
314 std::fs::create_dir_all(parent)?;
315 }
316 let mut file = std::fs::OpenOptions::new()
317 .create(true)
318 .append(true)
319 .open(&audit_path)?;
320 #[cfg(unix)]
321 if let Ok(meta) = file.metadata() {
322 use std::os::unix::fs::PermissionsExt;
323 if meta.permissions().mode() & 0o777 != 0o600
324 && let Err(e) =
325 std::fs::set_permissions(&audit_path, std::fs::Permissions::from_mode(0o600))
326 {
327 tracing::warn!(error = %e, path = %audit_path.display(), "failed to set keystore audit log permissions");
328 }
329 }
330
331 let redacted_key = key.map(redact_key_name);
332 let record = json!({
333 "timestamp": Utc::now().to_rfc3339(),
334 "operation": operation,
335 "key": redacted_key,
336 "pid": std::process::id(),
337 "process": std::env::args().next().unwrap_or_else(|| "unknown".to_string()),
338 "keystore_path": self.path,
339 "metadata": metadata
340 });
341 file.write_all(record.to_string().as_bytes())?;
342 file.write_all(b"\n")?;
343 file.flush()?;
344 Ok(())
345 }
346
347 fn save(&self) -> Result<()> {
348 let st = lock_or_recover(&self.state);
349 let entries = st
350 .entries
351 .as_ref()
352 .ok_or_else(|| RoboticusError::Keystore("keystore is locked".into()))?;
353
354 let passphrase = st
355 .passphrase
356 .as_ref()
357 .ok_or_else(|| RoboticusError::Keystore("no passphrase available".into()))?;
358
359 let salt = fresh_salt();
360 let key = derive_key(passphrase, &salt)?;
361
362 let store = KeystoreData {
363 entries: entries
364 .iter()
365 .map(|(k, v)| (k.clone(), (**v).clone()))
366 .collect(),
367 };
368 let plaintext = serde_json::to_vec(&store)?;
369
370 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
371 .map_err(|e| RoboticusError::Keystore(e.to_string()))?;
372
373 let mut nonce_bytes = [0u8; NONCE_LEN];
374 OsRng.fill_bytes(&mut nonce_bytes);
375 let nonce = Nonce::from_slice(&nonce_bytes);
376
377 let ciphertext = cipher
378 .encrypt(nonce, plaintext.as_ref())
379 .map_err(|e| RoboticusError::Keystore(format!("encryption failed: {e}")))?;
380
381 let mut out = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
382 out.extend_from_slice(&salt);
383 out.extend_from_slice(&nonce_bytes);
384 out.extend_from_slice(&ciphertext);
385
386 drop(st);
388
389 if let Some(parent) = self.path.parent() {
390 std::fs::create_dir_all(parent)?;
391 }
392
393 let tmp = self.path.with_extension("tmp");
394 std::fs::write(&tmp, &out)?;
395
396 #[cfg(unix)]
397 {
398 use std::os::unix::fs::PermissionsExt;
399 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
400 }
401
402 std::fs::rename(&tmp, &self.path)?;
403 let fingerprint = self.current_file_fingerprint();
404 let mut st = lock_or_recover(&self.state);
405 st.last_file_fingerprint = fingerprint;
406
407 Ok(())
408 }
409
410 fn decrypt_entries(&self, passphrase: &str) -> Result<HashMap<String, Zeroizing<String>>> {
411 let data = std::fs::read(&self.path)?;
412 if data.len() < SALT_LEN + NONCE_LEN + 1 {
413 return Err(RoboticusError::Keystore("corrupt keystore file".into()));
414 }
415
416 let salt = &data[..SALT_LEN];
417 let nonce_bytes = &data[SALT_LEN..SALT_LEN + NONCE_LEN];
418 let ciphertext = &data[SALT_LEN + NONCE_LEN..];
419
420 let key = derive_key(passphrase, salt)?;
421 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
422 .map_err(|e| RoboticusError::Keystore(e.to_string()))?;
423 let nonce = Nonce::from_slice(nonce_bytes);
424
425 let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
426 RoboticusError::Keystore("decryption failed (wrong passphrase?)".into())
427 })?;
428
429 let store: KeystoreData = serde_json::from_slice(&plaintext)
430 .map_err(|e| RoboticusError::Keystore(format!("corrupt keystore data: {e}")))?;
431
432 Ok(store
433 .entries
434 .into_iter()
435 .map(|(k, v)| (k, Zeroizing::new(v)))
436 .collect())
437 }
438
439 fn refresh_locked(&self, st: &mut KeystoreState) -> Result<()> {
440 if st.entries.is_none() {
441 return Ok(());
442 }
443 let Some(passphrase) = st.passphrase.as_ref() else {
444 return Ok(());
445 };
446 if !self.path.exists() {
447 return Ok(());
448 }
449 let current_fingerprint = self.current_file_fingerprint();
450 if current_fingerprint.is_some() && st.last_file_fingerprint == current_fingerprint {
451 return Ok(());
452 }
453 let refreshed = self.decrypt_entries(passphrase)?;
454 st.entries = Some(refreshed);
455 st.last_file_fingerprint = current_fingerprint;
456 Ok(())
457 }
458
459 fn current_file_fingerprint(&self) -> Option<(std::time::SystemTime, u64)> {
460 let meta = std::fs::metadata(&self.path).ok()?;
461 let modified = meta.modified().ok()?;
462 Some((modified, meta.len()))
463 }
464}
465
466fn derive_key(passphrase: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
467 let params = argon2::Params::new(65536, 3, 1, Some(32))
468 .map_err(|e| RoboticusError::Keystore(format!("argon2 params: {e}")))?;
469 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
470
471 let mut key = Zeroizing::new([0u8; 32]);
472 argon2
473 .hash_password_into(passphrase.as_bytes(), salt, key.as_mut())
474 .map_err(|e| RoboticusError::Keystore(format!("key derivation failed: {e}")))?;
475 Ok(key)
476}
477
478fn fresh_salt() -> [u8; SALT_LEN] {
479 let mut salt = [0u8; SALT_LEN];
480 OsRng.fill_bytes(&mut salt);
481 salt
482}
483
484fn redact_key_name(key: &str) -> String {
487 let visible: String = key.chars().take(3).collect();
488 format!("{visible}***")
489}
490
491fn machine_id_path() -> PathBuf {
493 crate::home_dir().join(".roboticus").join("machine-id")
494}
495
496fn machine_passphrase() -> String {
507 let id_path = machine_id_path();
508 let machine_id = match std::fs::read_to_string(&id_path) {
509 Ok(id) => {
510 let id = id.trim().to_string();
511 if id.is_empty() {
512 create_machine_id(&id_path)
513 } else {
514 id
515 }
516 }
517 Err(_) => create_machine_id(&id_path),
518 };
519 format!("roboticus-machine-key:{machine_id}")
520}
521
522fn create_machine_id(path: &std::path::Path) -> String {
524 let mut bytes = [0u8; 32];
525 OsRng.fill_bytes(&mut bytes);
526 let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
527
528 if let Some(parent) = path.parent() {
529 let _ = std::fs::create_dir_all(parent);
530 }
531 if let Err(e) = std::fs::write(path, &id) {
532 tracing::error!(error = %e, path = %path.display(), "failed to write machine-id; keystore will use ephemeral ID");
533 } else {
534 #[cfg(unix)]
535 {
536 use std::os::unix::fs::PermissionsExt;
537 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
538 }
539 tracing::info!(path = %path.display(), "created new machine-id for keystore");
540 }
541 id
542}
543
544fn legacy_passphrases() -> Vec<String> {
551 let syscall_hostname = gethostname::gethostname().to_string_lossy().into_owned();
552
553 let env_hostname = std::env::var("HOSTNAME")
554 .or_else(|_| std::env::var("HOST"))
555 .unwrap_or_else(|_| "unknown-host".into());
556
557 let username = std::env::var("USER")
558 .or_else(|_| std::env::var("USERNAME"))
559 .unwrap_or_else(|_| "unknown-user".into());
560
561 let mut candidates = Vec::new();
562
563 candidates.push(format!("ironclad-machine-key:{env_hostname}:{username}"));
565 if !syscall_hostname.is_empty() && syscall_hostname != env_hostname {
566 candidates.push(format!(
567 "ironclad-machine-key:{syscall_hostname}:{username}"
568 ));
569 }
570
571 candidates.push(format!("roboticus-machine-key:{env_hostname}:{username}"));
573 if !syscall_hostname.is_empty() && syscall_hostname != env_hostname {
574 candidates.push(format!(
575 "roboticus-machine-key:{syscall_hostname}:{username}"
576 ));
577 }
578
579 candidates
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::sync::Mutex;
586 use tempfile::NamedTempFile;
587
588 static MACHINE_ID_MUTEX: Mutex<()> = Mutex::new(());
593
594 fn temp_path() -> PathBuf {
595 let f = NamedTempFile::new().unwrap();
596 let p = f.path().to_path_buf();
597 drop(f);
598 p
599 }
600
601 #[test]
602 fn test_new_keystore_creates_empty() {
603 let path = temp_path();
604 let ks = Keystore::new(&path);
605 assert!(!ks.is_unlocked());
606
607 ks.unlock("test-pass").unwrap();
608 assert!(ks.is_unlocked());
609 assert!(ks.list_keys().is_empty());
610 assert!(path.exists());
611 }
612
613 #[test]
614 fn test_set_and_get() {
615 let path = temp_path();
616 let ks = Keystore::new(&path);
617 ks.unlock("pass").unwrap();
618
619 ks.set("api_key", "sk-123").unwrap();
620 assert_eq!(ks.get("api_key"), Some("sk-123".into()));
621 assert_eq!(ks.get("missing"), None);
622 }
623
624 #[test]
625 fn test_persistence() {
626 let path = temp_path();
627
628 {
629 let ks = Keystore::new(&path);
630 ks.unlock("my-pass").unwrap();
631 ks.set("secret", "value42").unwrap();
632 }
633
634 {
635 let ks = Keystore::new(&path);
636 assert!(!ks.is_unlocked());
637 ks.unlock("my-pass").unwrap();
638 assert_eq!(ks.get("secret"), Some("value42".into()));
639 }
640 }
641
642 #[test]
643 fn test_wrong_passphrase() {
644 let path = temp_path();
645 let ks = Keystore::new(&path);
646 ks.unlock("correct").unwrap();
647 ks.set("key", "val").unwrap();
648 drop(ks);
649
650 let ks2 = Keystore::new(&path);
651 let result = ks2.unlock("wrong");
652 assert!(result.is_err());
653 assert!(result.unwrap_err().to_string().contains("decryption"));
654 }
655
656 #[test]
657 fn test_list_keys() {
658 let path = temp_path();
659 let ks = Keystore::new(&path);
660 ks.unlock("pass").unwrap();
661
662 ks.set("alpha", "1").unwrap();
663 ks.set("beta", "2").unwrap();
664 ks.set("gamma", "3").unwrap();
665
666 let mut keys = ks.list_keys();
667 keys.sort();
668 assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
669 }
670
671 #[test]
672 fn test_remove() {
673 let path = temp_path();
674 let ks = Keystore::new(&path);
675 ks.unlock("pass").unwrap();
676
677 ks.set("keep", "a").unwrap();
678 ks.set("discard", "b").unwrap();
679
680 assert!(ks.remove("discard").unwrap());
681 assert!(!ks.remove("discard").unwrap());
682 assert_eq!(ks.get("discard"), None);
683 assert_eq!(ks.get("keep"), Some("a".into()));
684
685 drop(ks);
686 let ks2 = Keystore::new(&path);
687 ks2.unlock("pass").unwrap();
688 assert_eq!(ks2.get("discard"), None);
689 assert_eq!(ks2.get("keep"), Some("a".into()));
690 }
691
692 #[test]
693 fn test_import() {
694 let path = temp_path();
695 let ks = Keystore::new(&path);
696 ks.unlock("pass").unwrap();
697
698 let mut batch = HashMap::new();
699 batch.insert("k1".into(), "v1".into());
700 batch.insert("k2".into(), "v2".into());
701 batch.insert("k3".into(), "v3".into());
702
703 let count = ks.import(batch).unwrap();
704 assert_eq!(count, 3);
705 assert_eq!(ks.get("k1"), Some("v1".into()));
706 assert_eq!(ks.get("k2"), Some("v2".into()));
707 assert_eq!(ks.get("k3"), Some("v3".into()));
708 }
709
710 #[test]
711 fn test_machine_key() {
712 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
713 let path = temp_path();
714 let ks = Keystore::new(&path);
715 ks.unlock_machine().unwrap();
716 ks.set("service_key", "abc").unwrap();
717 drop(ks);
718
719 let ks2 = Keystore::new(&path);
720 ks2.unlock_machine().unwrap();
721 assert_eq!(ks2.get("service_key"), Some("abc".into()));
722 }
723
724 #[test]
725 fn test_get_refreshes_entries_after_external_write() {
726 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
727 let path = temp_path();
728 let ks_a = Keystore::new(&path);
729 ks_a.unlock_machine().unwrap();
730 ks_a.set("openai_api_key", "old-value").unwrap();
731 assert_eq!(ks_a.get("openai_api_key"), Some("old-value".into()));
732
733 let ks_b = Keystore::new(&path);
735 ks_b.unlock_machine().unwrap();
736 ks_b.set("openai_api_key", "new-value").unwrap();
737
738 assert_eq!(ks_a.get("openai_api_key"), Some("new-value".into()));
740 }
741
742 #[test]
743 fn test_lock_clears_memory() {
744 let path = temp_path();
745 let ks = Keystore::new(&path);
746 ks.unlock("pass").unwrap();
747 ks.set("secret", "hidden").unwrap();
748 assert!(ks.is_unlocked());
749
750 ks.lock();
751
752 assert!(!ks.is_unlocked());
753 assert_eq!(ks.get("secret"), None);
754 assert!(ks.list_keys().is_empty());
755 }
756
757 #[test]
758 fn test_rekey() {
759 let path = temp_path();
760 let ks = Keystore::new(&path);
761 ks.unlock("old-pass").unwrap();
762 ks.set("data", "preserved").unwrap();
763
764 ks.rekey("new-pass").unwrap();
765 drop(ks);
766
767 let ks2 = Keystore::new(&path);
768 assert!(ks2.unlock("old-pass").is_err());
769 ks2.unlock("new-pass").unwrap();
770 assert_eq!(ks2.get("data"), Some("preserved".into()));
771 }
772
773 #[test]
774 fn test_keystore_mutations_are_audited() {
775 let path = temp_path();
776 let ks = Keystore::new(&path);
777 ks.unlock("pass").unwrap();
778 ks.set("telegram_bot_token", "secret").unwrap();
779 assert!(ks.remove("telegram_bot_token").unwrap());
780 ks.rekey("new-pass").unwrap();
781
782 let audit_path = path.with_extension("audit.log");
783 let audit = std::fs::read_to_string(audit_path).unwrap();
784 assert!(audit.contains("\"operation\":\"initialize\""));
785 assert!(audit.contains("\"operation\":\"set\""));
786 assert!(audit.contains("\"operation\":\"remove\""));
787 assert!(audit.contains("\"operation\":\"rekey\""));
788 assert!(audit.contains("\"key\":\"tel***\""));
790 assert!(!audit.contains("telegram_bot_token"));
791 assert!(!audit.contains("secret"));
792 }
793
794 #[test]
795 fn test_default_path() {
796 let path = Keystore::default_path();
797 assert!(path.to_str().unwrap().contains("keystore.enc"));
798 assert!(path.to_str().unwrap().contains(".roboticus"));
799 }
800
801 #[test]
802 fn test_set_on_locked_keystore_fails() {
803 let path = temp_path();
804 let ks = Keystore::new(&path);
805 let result = ks.set("key", "value");
807 assert!(result.is_err());
808 assert!(result.unwrap_err().to_string().contains("locked"));
809 }
810
811 #[test]
812 fn test_remove_on_locked_keystore_fails() {
813 let path = temp_path();
814 let ks = Keystore::new(&path);
815 let result = ks.remove("key");
816 assert!(result.is_err());
817 assert!(result.unwrap_err().to_string().contains("locked"));
818 }
819
820 #[test]
821 fn test_import_on_locked_keystore_fails() {
822 let path = temp_path();
823 let ks = Keystore::new(&path);
824 let result = ks.import(HashMap::new());
825 assert!(result.is_err());
826 assert!(result.unwrap_err().to_string().contains("locked"));
827 }
828
829 #[test]
830 fn test_rekey_on_locked_keystore_fails() {
831 let path = temp_path();
832 let ks = Keystore::new(&path);
833 let result = ks.rekey("new-pass");
834 assert!(result.is_err());
835 assert!(result.unwrap_err().to_string().contains("locked"));
836 }
837
838 #[test]
839 fn test_get_on_locked_keystore_returns_none() {
840 let path = temp_path();
841 let ks = Keystore::new(&path);
842 assert_eq!(ks.get("anything"), None);
843 }
844
845 #[test]
846 fn test_list_keys_on_locked_keystore_returns_empty() {
847 let path = temp_path();
848 let ks = Keystore::new(&path);
849 assert!(ks.list_keys().is_empty());
850 }
851
852 #[test]
853 fn test_corrupt_keystore_file() {
854 let path = temp_path();
855 std::fs::write(&path, b"short").unwrap();
857 let ks = Keystore::new(&path);
858 let result = ks.unlock("pass");
859 assert!(result.is_err());
860 assert!(result.unwrap_err().to_string().contains("corrupt"));
861 }
862
863 #[test]
864 fn test_set_overwrites_existing_key() {
865 let path = temp_path();
866 let ks = Keystore::new(&path);
867 ks.unlock("pass").unwrap();
868
869 ks.set("key", "first").unwrap();
870 assert_eq!(ks.get("key"), Some("first".into()));
871
872 ks.set("key", "second").unwrap();
873 assert_eq!(ks.get("key"), Some("second".into()));
874 }
875
876 #[cfg(unix)]
877 #[test]
878 fn test_set_rolls_back_on_save_failure() {
879 use std::os::unix::fs::PermissionsExt;
880 let dir = tempfile::tempdir().unwrap();
881 let path = dir.path().join("keystore.enc");
882 let ks = Keystore::new(&path);
883 ks.unlock("pass").unwrap();
884 ks.set("stable", "1").unwrap();
885
886 let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
887 perms.set_mode(0o500);
888 std::fs::set_permissions(dir.path(), perms).unwrap();
889
890 let res = ks.set("transient", "2");
891 assert!(res.is_err());
892 assert_eq!(ks.get("stable"), Some("1".into()));
893 assert_eq!(ks.get("transient"), None);
894 let audit = std::fs::read_to_string(path.with_extension("audit.log")).unwrap();
895 assert!(audit.contains("\"operation\":\"set\""));
896 assert!(audit.contains("\"rolled_back\":true"));
897
898 let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
899 restore.set_mode(0o700);
900 std::fs::set_permissions(dir.path(), restore).unwrap();
901 }
902
903 #[test]
904 fn test_import_audit_entry() {
905 let path = temp_path();
906 let ks = Keystore::new(&path);
907 ks.unlock("pass").unwrap();
908
909 let mut batch = HashMap::new();
910 batch.insert("imported_key".into(), "imported_value".into());
911 ks.import(batch).unwrap();
912
913 let audit_path = path.with_extension("audit.log");
914 let audit = std::fs::read_to_string(audit_path).unwrap();
915 assert!(audit.contains("\"operation\":\"import\""));
916 }
917
918 #[test]
919 fn redact_key_name_short_keys() {
920 assert_eq!(redact_key_name("ab"), "ab***");
921 assert_eq!(redact_key_name("a"), "a***");
922 assert_eq!(redact_key_name(""), "***");
923 }
924
925 #[test]
926 fn redact_key_name_long_keys() {
927 assert_eq!(redact_key_name("telegram_bot_token"), "tel***");
928 assert_eq!(redact_key_name("abc"), "abc***");
929 }
930
931 #[test]
932 fn machine_passphrase_is_deterministic() {
933 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
934 let p1 = machine_passphrase();
935 let p2 = machine_passphrase();
936 assert_eq!(p1, p2);
937 assert!(p1.starts_with("roboticus-machine-key:"));
938 }
939
940 #[test]
941 fn machine_id_persists_across_calls() {
942 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
943 let id_path = machine_id_path();
946 let _ = std::fs::remove_file(&id_path);
947
948 let p1 = machine_passphrase();
951 let p2 = machine_passphrase();
952 assert_eq!(p1, p2);
953 assert!(id_path.exists());
954 let contents = std::fs::read_to_string(&id_path).unwrap();
955 assert_eq!(contents.trim().len(), 64); }
957
958 #[test]
959 fn legacy_passphrases_include_both_prefixes() {
960 let candidates = legacy_passphrases();
961 assert!(!candidates.is_empty());
962 assert!(
964 candidates
965 .iter()
966 .any(|p| p.starts_with("ironclad-machine-key:"))
967 );
968 assert!(
970 candidates
971 .iter()
972 .any(|p| p.starts_with("roboticus-machine-key:"))
973 );
974 }
975
976 #[test]
977 fn unlock_machine_migrates_legacy_keystore() {
978 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
979 let path = temp_path();
981 let candidates = legacy_passphrases();
982 let legacy_pass = &candidates[0]; let ks = Keystore::new(&path);
984 ks.unlock(legacy_pass).unwrap();
985 ks.set("secret", "migrated").unwrap();
986 drop(ks);
987
988 let ks2 = Keystore::new(&path);
990 ks2.unlock_machine().unwrap();
991 assert_eq!(ks2.get("secret"), Some("migrated".into()));
992
993 drop(ks2);
995 let primary = machine_passphrase();
996 let ks3 = Keystore::new(&path);
997 ks3.unlock(&primary).unwrap();
998 assert_eq!(ks3.get("secret"), Some("migrated".into()));
999 }
1000
1001 #[test]
1002 fn unlock_machine_recovers_pre_rebrand_keystore() {
1003 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
1004 let path = temp_path();
1006 let hostname = std::env::var("HOST")
1007 .or_else(|_| std::env::var("HOSTNAME"))
1008 .unwrap_or_else(|_| "unknown-host".into());
1009 let username = std::env::var("USER")
1010 .or_else(|_| std::env::var("USERNAME"))
1011 .unwrap_or_else(|_| "unknown-user".into());
1012 let old_pass = format!("ironclad-machine-key:{hostname}:{username}");
1013
1014 let ks = Keystore::new(&path);
1015 ks.unlock(&old_pass).unwrap();
1016 ks.set("discord_token", "abc123").unwrap();
1017 drop(ks);
1018
1019 let ks2 = Keystore::new(&path);
1021 ks2.unlock_machine().unwrap();
1022 assert_eq!(ks2.get("discord_token"), Some("abc123".into()));
1023 }
1024
1025 #[test]
1026 fn lock_or_recover_works_on_clean_mutex() {
1027 let m = Mutex::new(42);
1028 let guard = lock_or_recover(&m);
1029 assert_eq!(*guard, 42);
1030 }
1031
1032 #[test]
1033 fn audit_log_path_derives_from_keystore_path() {
1034 let ks = Keystore::new("/tmp/test.enc");
1035 assert_eq!(ks.audit_log_path(), PathBuf::from("/tmp/test.audit.log"));
1036 }
1037
1038 #[test]
1039 fn concurrent_set_and_rekey_no_deadlock() {
1040 let path = temp_path();
1041 let ks = Keystore::new(&path);
1042 ks.unlock("pass").unwrap();
1043 const ITERATIONS: usize = 10;
1044
1045 std::thread::scope(|s| {
1046 let ks1 = ks.clone();
1047 let ks2 = ks.clone();
1048
1049 let h1 = s.spawn(move || {
1050 for i in 0..ITERATIONS {
1051 ks1.set(&format!("key-{i}"), &format!("val-{i}")).unwrap();
1052 }
1053 });
1054 let h2 = s.spawn(move || {
1055 for _ in 0..ITERATIONS {
1056 ks2.rekey("pass").unwrap();
1057 }
1058 });
1059
1060 h1.join().unwrap();
1063 h2.join().unwrap();
1064 });
1065 }
1066}