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 {
496 if let Ok(test_dir) = std::env::var("ROBOTICUS_TEST_MACHINE_ID_DIR") {
497 return PathBuf::from(test_dir).join("machine-id");
498 }
499 crate::home_dir().join(".roboticus").join("machine-id")
500}
501
502fn machine_passphrase() -> String {
513 let id_path = machine_id_path();
514 let machine_id = match std::fs::read_to_string(&id_path) {
515 Ok(id) => {
516 let id = id.trim().to_string();
517 if id.is_empty() {
518 create_machine_id(&id_path)
519 } else {
520 id
521 }
522 }
523 Err(_) => create_machine_id(&id_path),
524 };
525 format!("roboticus-machine-key:{machine_id}")
526}
527
528fn create_machine_id(path: &std::path::Path) -> String {
530 let mut bytes = [0u8; 32];
531 OsRng.fill_bytes(&mut bytes);
532 let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
533
534 if let Some(parent) = path.parent() {
535 let _ = std::fs::create_dir_all(parent);
536 }
537 if let Err(e) = std::fs::write(path, &id) {
538 tracing::error!(error = %e, path = %path.display(), "failed to write machine-id; keystore will use ephemeral ID");
539 } else {
540 #[cfg(unix)]
541 {
542 use std::os::unix::fs::PermissionsExt;
543 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
544 }
545 tracing::info!(path = %path.display(), "created new machine-id for keystore");
546 }
547 id
548}
549
550fn legacy_passphrases() -> Vec<String> {
557 let syscall_hostname = gethostname::gethostname().to_string_lossy().into_owned();
558
559 let env_hostname = std::env::var("HOSTNAME")
560 .or_else(|_| std::env::var("HOST"))
561 .unwrap_or_else(|_| "unknown-host".into());
562
563 let username = std::env::var("USER")
564 .or_else(|_| std::env::var("USERNAME"))
565 .unwrap_or_else(|_| "unknown-user".into());
566
567 let mut candidates = Vec::new();
568
569 candidates.push(format!("ironclad-machine-key:{env_hostname}:{username}"));
571 if !syscall_hostname.is_empty() && syscall_hostname != env_hostname {
572 candidates.push(format!(
573 "ironclad-machine-key:{syscall_hostname}:{username}"
574 ));
575 }
576
577 candidates.push(format!("roboticus-machine-key:{env_hostname}:{username}"));
579 if !syscall_hostname.is_empty() && syscall_hostname != env_hostname {
580 candidates.push(format!(
581 "roboticus-machine-key:{syscall_hostname}:{username}"
582 ));
583 }
584
585 candidates
586}
587
588#[derive(Debug, Clone, PartialEq, Eq)]
597pub enum KeySource {
598 NotRequired,
600 OAuth,
602 Keystore(String),
604 EnvVar(String),
606 Missing,
608}
609
610impl KeySource {
611 pub fn status_pair(&self) -> (&'static str, &'static str) {
613 match self {
614 Self::NotRequired => ("not_required", "local"),
615 Self::OAuth => ("configured", "oauth"),
616 Self::Keystore(_) => ("configured", "keystore"),
617 Self::EnvVar(_) => ("configured", "env"),
618 Self::Missing => ("missing", "none"),
619 }
620 }
621
622 pub fn is_configured(&self) -> bool {
624 matches!(self, Self::Keystore(_) | Self::EnvVar(_) | Self::OAuth)
625 }
626}
627
628pub fn resolve_key_source(
640 provider_name: &str,
641 is_local: bool,
642 api_key_ref: Option<&str>,
643 api_key_env: Option<&str>,
644 auth_mode: Option<&str>,
645 keystore: &Keystore,
646) -> KeySource {
647 if is_local {
648 return KeySource::NotRequired;
649 }
650
651 if auth_mode.is_some_and(|m| m == "oauth") {
652 return KeySource::OAuth;
653 }
654
655 if let Some(ks_name) = api_key_ref.and_then(|r| r.strip_prefix("keystore:"))
656 && let Some(val) = keystore.get(ks_name)
657 && !val.is_empty()
658 {
659 return KeySource::Keystore(val);
660 }
661
662 let conventional = format!("{provider_name}_api_key");
663 if let Some(val) = keystore.get(&conventional)
664 && !val.is_empty()
665 {
666 return KeySource::Keystore(val);
667 }
668
669 if let Some(env_name) = api_key_env
670 && let Ok(val) = std::env::var(env_name)
671 && !val.is_empty()
672 {
673 return KeySource::EnvVar(val);
674 }
675
676 KeySource::Missing
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use std::sync::Mutex;
683 use tempfile::NamedTempFile;
684
685 static MACHINE_ID_MUTEX: Mutex<()> = Mutex::new(());
690
691 fn temp_path() -> PathBuf {
692 let f = NamedTempFile::new().unwrap();
693 let p = f.path().to_path_buf();
694 drop(f);
695 p
696 }
697
698 #[test]
699 fn test_new_keystore_creates_empty() {
700 let path = temp_path();
701 let ks = Keystore::new(&path);
702 assert!(!ks.is_unlocked());
703
704 ks.unlock("test-pass").unwrap();
705 assert!(ks.is_unlocked());
706 assert!(ks.list_keys().is_empty());
707 assert!(path.exists());
708 }
709
710 #[test]
711 fn test_set_and_get() {
712 let path = temp_path();
713 let ks = Keystore::new(&path);
714 ks.unlock("pass").unwrap();
715
716 ks.set("api_key", "sk-123").unwrap();
717 assert_eq!(ks.get("api_key"), Some("sk-123".into()));
718 assert_eq!(ks.get("missing"), None);
719 }
720
721 #[test]
722 fn test_persistence() {
723 let path = temp_path();
724
725 {
726 let ks = Keystore::new(&path);
727 ks.unlock("my-pass").unwrap();
728 ks.set("secret", "value42").unwrap();
729 }
730
731 {
732 let ks = Keystore::new(&path);
733 assert!(!ks.is_unlocked());
734 ks.unlock("my-pass").unwrap();
735 assert_eq!(ks.get("secret"), Some("value42".into()));
736 }
737 }
738
739 #[test]
740 fn test_wrong_passphrase() {
741 let path = temp_path();
742 let ks = Keystore::new(&path);
743 ks.unlock("correct").unwrap();
744 ks.set("key", "val").unwrap();
745 drop(ks);
746
747 let ks2 = Keystore::new(&path);
748 let result = ks2.unlock("wrong");
749 assert!(result.is_err());
750 assert!(result.unwrap_err().to_string().contains("decryption"));
751 }
752
753 #[test]
754 fn test_list_keys() {
755 let path = temp_path();
756 let ks = Keystore::new(&path);
757 ks.unlock("pass").unwrap();
758
759 ks.set("alpha", "1").unwrap();
760 ks.set("beta", "2").unwrap();
761 ks.set("gamma", "3").unwrap();
762
763 let mut keys = ks.list_keys();
764 keys.sort();
765 assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
766 }
767
768 #[test]
769 fn test_remove() {
770 let path = temp_path();
771 let ks = Keystore::new(&path);
772 ks.unlock("pass").unwrap();
773
774 ks.set("keep", "a").unwrap();
775 ks.set("discard", "b").unwrap();
776
777 assert!(ks.remove("discard").unwrap());
778 assert!(!ks.remove("discard").unwrap());
779 assert_eq!(ks.get("discard"), None);
780 assert_eq!(ks.get("keep"), Some("a".into()));
781
782 drop(ks);
783 let ks2 = Keystore::new(&path);
784 ks2.unlock("pass").unwrap();
785 assert_eq!(ks2.get("discard"), None);
786 assert_eq!(ks2.get("keep"), Some("a".into()));
787 }
788
789 #[test]
790 fn test_import() {
791 let path = temp_path();
792 let ks = Keystore::new(&path);
793 ks.unlock("pass").unwrap();
794
795 let mut batch = HashMap::new();
796 batch.insert("k1".into(), "v1".into());
797 batch.insert("k2".into(), "v2".into());
798 batch.insert("k3".into(), "v3".into());
799
800 let count = ks.import(batch).unwrap();
801 assert_eq!(count, 3);
802 assert_eq!(ks.get("k1"), Some("v1".into()));
803 assert_eq!(ks.get("k2"), Some("v2".into()));
804 assert_eq!(ks.get("k3"), Some("v3".into()));
805 }
806
807 #[test]
808 fn test_machine_key() {
809 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
810 let path = temp_path();
811 let ks = Keystore::new(&path);
812 ks.unlock_machine().unwrap();
813 ks.set("service_key", "abc").unwrap();
814 drop(ks);
815
816 let ks2 = Keystore::new(&path);
817 ks2.unlock_machine().unwrap();
818 assert_eq!(ks2.get("service_key"), Some("abc".into()));
819 }
820
821 #[test]
822 fn test_get_refreshes_entries_after_external_write() {
823 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
824 let path = temp_path();
825 let ks_a = Keystore::new(&path);
826 ks_a.unlock_machine().unwrap();
827 ks_a.set("openai_api_key", "old-value").unwrap();
828 assert_eq!(ks_a.get("openai_api_key"), Some("old-value".into()));
829
830 let ks_b = Keystore::new(&path);
832 ks_b.unlock_machine().unwrap();
833 ks_b.set("openai_api_key", "new-value").unwrap();
834
835 assert_eq!(ks_a.get("openai_api_key"), Some("new-value".into()));
837 }
838
839 #[test]
840 fn test_lock_clears_memory() {
841 let path = temp_path();
842 let ks = Keystore::new(&path);
843 ks.unlock("pass").unwrap();
844 ks.set("secret", "hidden").unwrap();
845 assert!(ks.is_unlocked());
846
847 ks.lock();
848
849 assert!(!ks.is_unlocked());
850 assert_eq!(ks.get("secret"), None);
851 assert!(ks.list_keys().is_empty());
852 }
853
854 #[test]
855 fn test_rekey() {
856 let path = temp_path();
857 let ks = Keystore::new(&path);
858 ks.unlock("old-pass").unwrap();
859 ks.set("data", "preserved").unwrap();
860
861 ks.rekey("new-pass").unwrap();
862 drop(ks);
863
864 let ks2 = Keystore::new(&path);
865 assert!(ks2.unlock("old-pass").is_err());
866 ks2.unlock("new-pass").unwrap();
867 assert_eq!(ks2.get("data"), Some("preserved".into()));
868 }
869
870 #[test]
871 fn test_keystore_mutations_are_audited() {
872 let path = temp_path();
873 let ks = Keystore::new(&path);
874 ks.unlock("pass").unwrap();
875 ks.set("telegram_bot_token", "secret").unwrap();
876 assert!(ks.remove("telegram_bot_token").unwrap());
877 ks.rekey("new-pass").unwrap();
878
879 let audit_path = path.with_extension("audit.log");
880 let audit = std::fs::read_to_string(audit_path).unwrap();
881 assert!(audit.contains("\"operation\":\"initialize\""));
882 assert!(audit.contains("\"operation\":\"set\""));
883 assert!(audit.contains("\"operation\":\"remove\""));
884 assert!(audit.contains("\"operation\":\"rekey\""));
885 assert!(audit.contains("\"key\":\"tel***\""));
887 assert!(!audit.contains("telegram_bot_token"));
888 assert!(!audit.contains("secret"));
889 }
890
891 #[test]
892 fn test_default_path() {
893 let path = Keystore::default_path();
894 assert!(path.to_str().unwrap().contains("keystore.enc"));
895 assert!(path.to_str().unwrap().contains(".roboticus"));
896 }
897
898 #[test]
899 fn test_set_on_locked_keystore_fails() {
900 let path = temp_path();
901 let ks = Keystore::new(&path);
902 let result = ks.set("key", "value");
904 assert!(result.is_err());
905 assert!(result.unwrap_err().to_string().contains("locked"));
906 }
907
908 #[test]
909 fn test_remove_on_locked_keystore_fails() {
910 let path = temp_path();
911 let ks = Keystore::new(&path);
912 let result = ks.remove("key");
913 assert!(result.is_err());
914 assert!(result.unwrap_err().to_string().contains("locked"));
915 }
916
917 #[test]
918 fn test_import_on_locked_keystore_fails() {
919 let path = temp_path();
920 let ks = Keystore::new(&path);
921 let result = ks.import(HashMap::new());
922 assert!(result.is_err());
923 assert!(result.unwrap_err().to_string().contains("locked"));
924 }
925
926 #[test]
927 fn test_rekey_on_locked_keystore_fails() {
928 let path = temp_path();
929 let ks = Keystore::new(&path);
930 let result = ks.rekey("new-pass");
931 assert!(result.is_err());
932 assert!(result.unwrap_err().to_string().contains("locked"));
933 }
934
935 #[test]
936 fn test_get_on_locked_keystore_returns_none() {
937 let path = temp_path();
938 let ks = Keystore::new(&path);
939 assert_eq!(ks.get("anything"), None);
940 }
941
942 #[test]
943 fn test_list_keys_on_locked_keystore_returns_empty() {
944 let path = temp_path();
945 let ks = Keystore::new(&path);
946 assert!(ks.list_keys().is_empty());
947 }
948
949 #[test]
950 fn test_corrupt_keystore_file() {
951 let path = temp_path();
952 std::fs::write(&path, b"short").unwrap();
954 let ks = Keystore::new(&path);
955 let result = ks.unlock("pass");
956 assert!(result.is_err());
957 assert!(result.unwrap_err().to_string().contains("corrupt"));
958 }
959
960 #[test]
961 fn test_set_overwrites_existing_key() {
962 let path = temp_path();
963 let ks = Keystore::new(&path);
964 ks.unlock("pass").unwrap();
965
966 ks.set("key", "first").unwrap();
967 assert_eq!(ks.get("key"), Some("first".into()));
968
969 ks.set("key", "second").unwrap();
970 assert_eq!(ks.get("key"), Some("second".into()));
971 }
972
973 #[cfg(unix)]
974 #[test]
975 fn test_set_rolls_back_on_save_failure() {
976 use std::os::unix::fs::PermissionsExt;
977 let dir = tempfile::tempdir().unwrap();
978 let path = dir.path().join("keystore.enc");
979 let ks = Keystore::new(&path);
980 ks.unlock("pass").unwrap();
981 ks.set("stable", "1").unwrap();
982
983 let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
984 perms.set_mode(0o500);
985 std::fs::set_permissions(dir.path(), perms).unwrap();
986
987 let res = ks.set("transient", "2");
988 assert!(res.is_err());
989 assert_eq!(ks.get("stable"), Some("1".into()));
990 assert_eq!(ks.get("transient"), None);
991 let audit = std::fs::read_to_string(path.with_extension("audit.log")).unwrap();
992 assert!(audit.contains("\"operation\":\"set\""));
993 assert!(audit.contains("\"rolled_back\":true"));
994
995 let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
996 restore.set_mode(0o700);
997 std::fs::set_permissions(dir.path(), restore).unwrap();
998 }
999
1000 #[test]
1001 fn test_import_audit_entry() {
1002 let path = temp_path();
1003 let ks = Keystore::new(&path);
1004 ks.unlock("pass").unwrap();
1005
1006 let mut batch = HashMap::new();
1007 batch.insert("imported_key".into(), "imported_value".into());
1008 ks.import(batch).unwrap();
1009
1010 let audit_path = path.with_extension("audit.log");
1011 let audit = std::fs::read_to_string(audit_path).unwrap();
1012 assert!(audit.contains("\"operation\":\"import\""));
1013 }
1014
1015 #[test]
1016 fn redact_key_name_short_keys() {
1017 assert_eq!(redact_key_name("ab"), "ab***");
1018 assert_eq!(redact_key_name("a"), "a***");
1019 assert_eq!(redact_key_name(""), "***");
1020 }
1021
1022 #[test]
1023 fn redact_key_name_long_keys() {
1024 assert_eq!(redact_key_name("telegram_bot_token"), "tel***");
1025 assert_eq!(redact_key_name("abc"), "abc***");
1026 }
1027
1028 fn with_test_machine_id_dir<F: FnOnce()>(f: F) {
1034 let dir = tempfile::tempdir().unwrap();
1035 unsafe { std::env::set_var("ROBOTICUS_TEST_MACHINE_ID_DIR", dir.path()) };
1036 f();
1037 unsafe { std::env::remove_var("ROBOTICUS_TEST_MACHINE_ID_DIR") };
1038 }
1039
1040 #[test]
1041 fn machine_passphrase_is_deterministic() {
1042 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
1043 with_test_machine_id_dir(|| {
1044 let p1 = machine_passphrase();
1045 let p2 = machine_passphrase();
1046 assert_eq!(p1, p2);
1047 assert!(p1.starts_with("roboticus-machine-key:"));
1048 });
1049 }
1050
1051 #[test]
1052 fn machine_id_persists_across_calls() {
1053 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
1054 with_test_machine_id_dir(|| {
1055 let p1 = machine_passphrase();
1058 let p2 = machine_passphrase();
1059 assert_eq!(p1, p2);
1060 let id_path = machine_id_path();
1061 assert!(id_path.exists());
1062 let contents = std::fs::read_to_string(&id_path).unwrap();
1063 assert_eq!(contents.trim().len(), 64); });
1065 }
1066
1067 #[test]
1068 fn legacy_passphrases_include_both_prefixes() {
1069 let candidates = legacy_passphrases();
1070 assert!(!candidates.is_empty());
1071 assert!(
1073 candidates
1074 .iter()
1075 .any(|p| p.starts_with("ironclad-machine-key:"))
1076 );
1077 assert!(
1079 candidates
1080 .iter()
1081 .any(|p| p.starts_with("roboticus-machine-key:"))
1082 );
1083 }
1084
1085 #[test]
1086 fn unlock_machine_migrates_legacy_keystore() {
1087 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
1088 with_test_machine_id_dir(|| {
1089 let path = temp_path();
1091 let candidates = legacy_passphrases();
1092 let legacy_pass = &candidates[0]; let ks = Keystore::new(&path);
1094 ks.unlock(legacy_pass).unwrap();
1095 ks.set("secret", "migrated").unwrap();
1096 drop(ks);
1097
1098 let ks2 = Keystore::new(&path);
1100 ks2.unlock_machine().unwrap();
1101 assert_eq!(ks2.get("secret"), Some("migrated".into()));
1102
1103 drop(ks2);
1105 let primary = machine_passphrase();
1106 let ks3 = Keystore::new(&path);
1107 ks3.unlock(&primary).unwrap();
1108 assert_eq!(ks3.get("secret"), Some("migrated".into()));
1109 });
1110 }
1111
1112 #[test]
1113 fn unlock_machine_recovers_pre_rebrand_keystore() {
1114 let _lock = MACHINE_ID_MUTEX.lock().unwrap();
1115 let path = temp_path();
1117 let hostname = std::env::var("HOST")
1118 .or_else(|_| std::env::var("HOSTNAME"))
1119 .unwrap_or_else(|_| "unknown-host".into());
1120 let username = std::env::var("USER")
1121 .or_else(|_| std::env::var("USERNAME"))
1122 .unwrap_or_else(|_| "unknown-user".into());
1123 let old_pass = format!("ironclad-machine-key:{hostname}:{username}");
1124
1125 let ks = Keystore::new(&path);
1126 ks.unlock(&old_pass).unwrap();
1127 ks.set("discord_token", "abc123").unwrap();
1128 drop(ks);
1129
1130 let ks2 = Keystore::new(&path);
1132 ks2.unlock_machine().unwrap();
1133 assert_eq!(ks2.get("discord_token"), Some("abc123".into()));
1134 }
1135
1136 #[test]
1137 fn lock_or_recover_works_on_clean_mutex() {
1138 let m = Mutex::new(42);
1139 let guard = lock_or_recover(&m);
1140 assert_eq!(*guard, 42);
1141 }
1142
1143 #[test]
1144 fn audit_log_path_derives_from_keystore_path() {
1145 let ks = Keystore::new("/tmp/test.enc");
1146 assert_eq!(ks.audit_log_path(), PathBuf::from("/tmp/test.audit.log"));
1147 }
1148
1149 #[test]
1150 fn concurrent_set_and_rekey_no_deadlock() {
1151 let path = temp_path();
1152 let ks = Keystore::new(&path);
1153 ks.unlock("pass").unwrap();
1154 const ITERATIONS: usize = 10;
1155
1156 std::thread::scope(|s| {
1157 let ks1 = ks.clone();
1158 let ks2 = ks.clone();
1159
1160 let h1 = s.spawn(move || {
1161 for i in 0..ITERATIONS {
1162 ks1.set(&format!("key-{i}"), &format!("val-{i}")).unwrap();
1163 }
1164 });
1165 let h2 = s.spawn(move || {
1166 for _ in 0..ITERATIONS {
1167 ks2.rekey("pass").unwrap();
1168 }
1169 });
1170
1171 h1.join().unwrap();
1174 h2.join().unwrap();
1175 });
1176 }
1177}