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 tempfile::NamedTempFile;
586
587 fn temp_path() -> PathBuf {
588 let f = NamedTempFile::new().unwrap();
589 let p = f.path().to_path_buf();
590 drop(f);
591 p
592 }
593
594 #[test]
595 fn test_new_keystore_creates_empty() {
596 let path = temp_path();
597 let ks = Keystore::new(&path);
598 assert!(!ks.is_unlocked());
599
600 ks.unlock("test-pass").unwrap();
601 assert!(ks.is_unlocked());
602 assert!(ks.list_keys().is_empty());
603 assert!(path.exists());
604 }
605
606 #[test]
607 fn test_set_and_get() {
608 let path = temp_path();
609 let ks = Keystore::new(&path);
610 ks.unlock("pass").unwrap();
611
612 ks.set("api_key", "sk-123").unwrap();
613 assert_eq!(ks.get("api_key"), Some("sk-123".into()));
614 assert_eq!(ks.get("missing"), None);
615 }
616
617 #[test]
618 fn test_persistence() {
619 let path = temp_path();
620
621 {
622 let ks = Keystore::new(&path);
623 ks.unlock("my-pass").unwrap();
624 ks.set("secret", "value42").unwrap();
625 }
626
627 {
628 let ks = Keystore::new(&path);
629 assert!(!ks.is_unlocked());
630 ks.unlock("my-pass").unwrap();
631 assert_eq!(ks.get("secret"), Some("value42".into()));
632 }
633 }
634
635 #[test]
636 fn test_wrong_passphrase() {
637 let path = temp_path();
638 let ks = Keystore::new(&path);
639 ks.unlock("correct").unwrap();
640 ks.set("key", "val").unwrap();
641 drop(ks);
642
643 let ks2 = Keystore::new(&path);
644 let result = ks2.unlock("wrong");
645 assert!(result.is_err());
646 assert!(result.unwrap_err().to_string().contains("decryption"));
647 }
648
649 #[test]
650 fn test_list_keys() {
651 let path = temp_path();
652 let ks = Keystore::new(&path);
653 ks.unlock("pass").unwrap();
654
655 ks.set("alpha", "1").unwrap();
656 ks.set("beta", "2").unwrap();
657 ks.set("gamma", "3").unwrap();
658
659 let mut keys = ks.list_keys();
660 keys.sort();
661 assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
662 }
663
664 #[test]
665 fn test_remove() {
666 let path = temp_path();
667 let ks = Keystore::new(&path);
668 ks.unlock("pass").unwrap();
669
670 ks.set("keep", "a").unwrap();
671 ks.set("discard", "b").unwrap();
672
673 assert!(ks.remove("discard").unwrap());
674 assert!(!ks.remove("discard").unwrap());
675 assert_eq!(ks.get("discard"), None);
676 assert_eq!(ks.get("keep"), Some("a".into()));
677
678 drop(ks);
679 let ks2 = Keystore::new(&path);
680 ks2.unlock("pass").unwrap();
681 assert_eq!(ks2.get("discard"), None);
682 assert_eq!(ks2.get("keep"), Some("a".into()));
683 }
684
685 #[test]
686 fn test_import() {
687 let path = temp_path();
688 let ks = Keystore::new(&path);
689 ks.unlock("pass").unwrap();
690
691 let mut batch = HashMap::new();
692 batch.insert("k1".into(), "v1".into());
693 batch.insert("k2".into(), "v2".into());
694 batch.insert("k3".into(), "v3".into());
695
696 let count = ks.import(batch).unwrap();
697 assert_eq!(count, 3);
698 assert_eq!(ks.get("k1"), Some("v1".into()));
699 assert_eq!(ks.get("k2"), Some("v2".into()));
700 assert_eq!(ks.get("k3"), Some("v3".into()));
701 }
702
703 #[test]
704 fn test_machine_key() {
705 let path = temp_path();
706 let ks = Keystore::new(&path);
707 ks.unlock_machine().unwrap();
708 ks.set("service_key", "abc").unwrap();
709 drop(ks);
710
711 let ks2 = Keystore::new(&path);
712 ks2.unlock_machine().unwrap();
713 assert_eq!(ks2.get("service_key"), Some("abc".into()));
714 }
715
716 #[test]
717 fn test_get_refreshes_entries_after_external_write() {
718 let path = temp_path();
719 let ks_a = Keystore::new(&path);
720 ks_a.unlock_machine().unwrap();
721 ks_a.set("openai_api_key", "old-value").unwrap();
722 assert_eq!(ks_a.get("openai_api_key"), Some("old-value".into()));
723
724 let ks_b = Keystore::new(&path);
726 ks_b.unlock_machine().unwrap();
727 ks_b.set("openai_api_key", "new-value").unwrap();
728
729 assert_eq!(ks_a.get("openai_api_key"), Some("new-value".into()));
731 }
732
733 #[test]
734 fn test_lock_clears_memory() {
735 let path = temp_path();
736 let ks = Keystore::new(&path);
737 ks.unlock("pass").unwrap();
738 ks.set("secret", "hidden").unwrap();
739 assert!(ks.is_unlocked());
740
741 ks.lock();
742
743 assert!(!ks.is_unlocked());
744 assert_eq!(ks.get("secret"), None);
745 assert!(ks.list_keys().is_empty());
746 }
747
748 #[test]
749 fn test_rekey() {
750 let path = temp_path();
751 let ks = Keystore::new(&path);
752 ks.unlock("old-pass").unwrap();
753 ks.set("data", "preserved").unwrap();
754
755 ks.rekey("new-pass").unwrap();
756 drop(ks);
757
758 let ks2 = Keystore::new(&path);
759 assert!(ks2.unlock("old-pass").is_err());
760 ks2.unlock("new-pass").unwrap();
761 assert_eq!(ks2.get("data"), Some("preserved".into()));
762 }
763
764 #[test]
765 fn test_keystore_mutations_are_audited() {
766 let path = temp_path();
767 let ks = Keystore::new(&path);
768 ks.unlock("pass").unwrap();
769 ks.set("telegram_bot_token", "secret").unwrap();
770 assert!(ks.remove("telegram_bot_token").unwrap());
771 ks.rekey("new-pass").unwrap();
772
773 let audit_path = path.with_extension("audit.log");
774 let audit = std::fs::read_to_string(audit_path).unwrap();
775 assert!(audit.contains("\"operation\":\"initialize\""));
776 assert!(audit.contains("\"operation\":\"set\""));
777 assert!(audit.contains("\"operation\":\"remove\""));
778 assert!(audit.contains("\"operation\":\"rekey\""));
779 assert!(audit.contains("\"key\":\"tel***\""));
781 assert!(!audit.contains("telegram_bot_token"));
782 assert!(!audit.contains("secret"));
783 }
784
785 #[test]
786 fn test_default_path() {
787 let path = Keystore::default_path();
788 assert!(path.to_str().unwrap().contains("keystore.enc"));
789 assert!(path.to_str().unwrap().contains(".roboticus"));
790 }
791
792 #[test]
793 fn test_set_on_locked_keystore_fails() {
794 let path = temp_path();
795 let ks = Keystore::new(&path);
796 let result = ks.set("key", "value");
798 assert!(result.is_err());
799 assert!(result.unwrap_err().to_string().contains("locked"));
800 }
801
802 #[test]
803 fn test_remove_on_locked_keystore_fails() {
804 let path = temp_path();
805 let ks = Keystore::new(&path);
806 let result = ks.remove("key");
807 assert!(result.is_err());
808 assert!(result.unwrap_err().to_string().contains("locked"));
809 }
810
811 #[test]
812 fn test_import_on_locked_keystore_fails() {
813 let path = temp_path();
814 let ks = Keystore::new(&path);
815 let result = ks.import(HashMap::new());
816 assert!(result.is_err());
817 assert!(result.unwrap_err().to_string().contains("locked"));
818 }
819
820 #[test]
821 fn test_rekey_on_locked_keystore_fails() {
822 let path = temp_path();
823 let ks = Keystore::new(&path);
824 let result = ks.rekey("new-pass");
825 assert!(result.is_err());
826 assert!(result.unwrap_err().to_string().contains("locked"));
827 }
828
829 #[test]
830 fn test_get_on_locked_keystore_returns_none() {
831 let path = temp_path();
832 let ks = Keystore::new(&path);
833 assert_eq!(ks.get("anything"), None);
834 }
835
836 #[test]
837 fn test_list_keys_on_locked_keystore_returns_empty() {
838 let path = temp_path();
839 let ks = Keystore::new(&path);
840 assert!(ks.list_keys().is_empty());
841 }
842
843 #[test]
844 fn test_corrupt_keystore_file() {
845 let path = temp_path();
846 std::fs::write(&path, b"short").unwrap();
848 let ks = Keystore::new(&path);
849 let result = ks.unlock("pass");
850 assert!(result.is_err());
851 assert!(result.unwrap_err().to_string().contains("corrupt"));
852 }
853
854 #[test]
855 fn test_set_overwrites_existing_key() {
856 let path = temp_path();
857 let ks = Keystore::new(&path);
858 ks.unlock("pass").unwrap();
859
860 ks.set("key", "first").unwrap();
861 assert_eq!(ks.get("key"), Some("first".into()));
862
863 ks.set("key", "second").unwrap();
864 assert_eq!(ks.get("key"), Some("second".into()));
865 }
866
867 #[cfg(unix)]
868 #[test]
869 fn test_set_rolls_back_on_save_failure() {
870 use std::os::unix::fs::PermissionsExt;
871 let dir = tempfile::tempdir().unwrap();
872 let path = dir.path().join("keystore.enc");
873 let ks = Keystore::new(&path);
874 ks.unlock("pass").unwrap();
875 ks.set("stable", "1").unwrap();
876
877 let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
878 perms.set_mode(0o500);
879 std::fs::set_permissions(dir.path(), perms).unwrap();
880
881 let res = ks.set("transient", "2");
882 assert!(res.is_err());
883 assert_eq!(ks.get("stable"), Some("1".into()));
884 assert_eq!(ks.get("transient"), None);
885 let audit = std::fs::read_to_string(path.with_extension("audit.log")).unwrap();
886 assert!(audit.contains("\"operation\":\"set\""));
887 assert!(audit.contains("\"rolled_back\":true"));
888
889 let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
890 restore.set_mode(0o700);
891 std::fs::set_permissions(dir.path(), restore).unwrap();
892 }
893
894 #[test]
895 fn test_import_audit_entry() {
896 let path = temp_path();
897 let ks = Keystore::new(&path);
898 ks.unlock("pass").unwrap();
899
900 let mut batch = HashMap::new();
901 batch.insert("imported_key".into(), "imported_value".into());
902 ks.import(batch).unwrap();
903
904 let audit_path = path.with_extension("audit.log");
905 let audit = std::fs::read_to_string(audit_path).unwrap();
906 assert!(audit.contains("\"operation\":\"import\""));
907 }
908
909 #[test]
910 fn redact_key_name_short_keys() {
911 assert_eq!(redact_key_name("ab"), "ab***");
912 assert_eq!(redact_key_name("a"), "a***");
913 assert_eq!(redact_key_name(""), "***");
914 }
915
916 #[test]
917 fn redact_key_name_long_keys() {
918 assert_eq!(redact_key_name("telegram_bot_token"), "tel***");
919 assert_eq!(redact_key_name("abc"), "abc***");
920 }
921
922 #[test]
923 fn machine_passphrase_is_deterministic() {
924 let p1 = machine_passphrase();
925 let p2 = machine_passphrase();
926 assert_eq!(p1, p2);
927 assert!(p1.starts_with("roboticus-machine-key:"));
928 }
929
930 #[test]
931 fn machine_id_persists_across_calls() {
932 let p1 = machine_passphrase();
935 let p2 = machine_passphrase();
936 assert_eq!(p1, p2);
937 let id_path = machine_id_path();
938 assert!(id_path.exists());
939 let contents = std::fs::read_to_string(&id_path).unwrap();
940 assert_eq!(contents.trim().len(), 64); }
942
943 #[test]
944 fn legacy_passphrases_include_both_prefixes() {
945 let candidates = legacy_passphrases();
946 assert!(!candidates.is_empty());
947 assert!(
949 candidates
950 .iter()
951 .any(|p| p.starts_with("ironclad-machine-key:"))
952 );
953 assert!(
955 candidates
956 .iter()
957 .any(|p| p.starts_with("roboticus-machine-key:"))
958 );
959 }
960
961 #[test]
962 fn unlock_machine_migrates_legacy_keystore() {
963 let path = temp_path();
965 let candidates = legacy_passphrases();
966 let legacy_pass = &candidates[0]; let ks = Keystore::new(&path);
968 ks.unlock(legacy_pass).unwrap();
969 ks.set("secret", "migrated").unwrap();
970 drop(ks);
971
972 let ks2 = Keystore::new(&path);
974 ks2.unlock_machine().unwrap();
975 assert_eq!(ks2.get("secret"), Some("migrated".into()));
976
977 drop(ks2);
979 let primary = machine_passphrase();
980 let ks3 = Keystore::new(&path);
981 ks3.unlock(&primary).unwrap();
982 assert_eq!(ks3.get("secret"), Some("migrated".into()));
983 }
984
985 #[test]
986 fn unlock_machine_recovers_pre_rebrand_keystore() {
987 let path = temp_path();
989 let hostname = std::env::var("HOST")
990 .or_else(|_| std::env::var("HOSTNAME"))
991 .unwrap_or_else(|_| "unknown-host".into());
992 let username = std::env::var("USER")
993 .or_else(|_| std::env::var("USERNAME"))
994 .unwrap_or_else(|_| "unknown-user".into());
995 let old_pass = format!("ironclad-machine-key:{hostname}:{username}");
996
997 let ks = Keystore::new(&path);
998 ks.unlock(&old_pass).unwrap();
999 ks.set("discord_token", "abc123").unwrap();
1000 drop(ks);
1001
1002 let ks2 = Keystore::new(&path);
1004 ks2.unlock_machine().unwrap();
1005 assert_eq!(ks2.get("discord_token"), Some("abc123".into()));
1006 }
1007
1008 #[test]
1009 fn lock_or_recover_works_on_clean_mutex() {
1010 let m = Mutex::new(42);
1011 let guard = lock_or_recover(&m);
1012 assert_eq!(*guard, 42);
1013 }
1014
1015 #[test]
1016 fn audit_log_path_derives_from_keystore_path() {
1017 let ks = Keystore::new("/tmp/test.enc");
1018 assert_eq!(ks.audit_log_path(), PathBuf::from("/tmp/test.audit.log"));
1019 }
1020
1021 #[test]
1022 fn concurrent_set_and_rekey_no_deadlock() {
1023 let path = temp_path();
1024 let ks = Keystore::new(&path);
1025 ks.unlock("pass").unwrap();
1026 const ITERATIONS: usize = 10;
1027
1028 std::thread::scope(|s| {
1029 let ks1 = ks.clone();
1030 let ks2 = ks.clone();
1031
1032 let h1 = s.spawn(move || {
1033 for i in 0..ITERATIONS {
1034 ks1.set(&format!("key-{i}"), &format!("val-{i}")).unwrap();
1035 }
1036 });
1037 let h2 = s.spawn(move || {
1038 for _ in 0..ITERATIONS {
1039 ks2.rekey("pass").unwrap();
1040 }
1041 });
1042
1043 h1.join().unwrap();
1046 h2.join().unwrap();
1047 });
1048 }
1049}