Skip to main content

roboticus_core/
keystore.rs

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
20/// Acquires the mutex, recovering from poison if a prior holder panicked.
21///
22/// Rationale: the keystore is an in-memory cache backed by an encrypted file.
23/// If a thread panics mid-update the in-memory state may be stale but not
24/// corrupt -- the on-disk file remains the source of truth and will be
25/// re-read on the next `refresh_locked` call. Panicking here would make
26/// the entire keystore permanently unavailable, which is worse than serving
27/// a potentially stale cache.
28fn 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
37/// Combined in-memory state behind a single mutex, eliminating lock ordering
38/// concerns that existed with the previous two-mutex design.
39struct 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    /// Unlock with a deterministic machine-derived passphrase (hostname + username).
95    ///
96    /// **Security note:** This provides convenience-only protection. The passphrase
97    /// is derived from publicly-known values and does NOT protect against local
98    /// attackers who know the machine's hostname and username. Use a user-supplied
99    /// passphrase via `unlock()` for secrets requiring real confidentiality.
100    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    /// Re-encrypt with a new passphrase. Must already be unlocked.
245    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 lock before filesystem I/O to avoid holding it during blocking ops.
361        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
458/// Redact a key name for audit logging: show the first 3 characters followed
459/// by `***` so that logs are useful for debugging without exposing full names.
460fn redact_key_name(key: &str) -> String {
461    let visible: String = key.chars().take(3).collect();
462    format!("{visible}***")
463}
464
465// SECURITY WARNING: `machine_passphrase` derives its passphrase from the local
466// hostname and username -- values that are trivially discoverable by any process
467// on the same machine. This provides protection only against casual access (e.g.
468// the keystore file being copied to a different machine). It does NOT protect
469// against targeted local attackers who can read environment variables or run
470// `whoami`/`hostname`. For secrets requiring real confidentiality, callers should
471// use `Keystore::unlock()` with a user-supplied passphrase instead.
472fn 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        // Simulate a second process mutating the same keystore file.
625        let ks_b = Keystore::new(&path);
626        ks_b.unlock_machine().unwrap();
627        ks_b.set("openai_api_key", "new-value").unwrap();
628
629        // Without a refresh-on-read policy this can stay stale in ks_a.
630        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        // Key names are redacted: only first 3 chars visible, followed by ***
680        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        // Don't unlock
697        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        // Write too-short data (less than SALT_LEN + NONCE_LEN + 1)
747        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            // Both threads must complete (no deadlock); 5-second implicit timeout
865            // via std::thread::scope waiting for spawned threads.
866            h1.join().unwrap();
867            h2.join().unwrap();
868        });
869    }
870}