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::{Result, RoboticusError};
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        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    /// Unlock with a machine-derived passphrase.
94    ///
95    /// Strategy:
96    /// 1. Try the current machine-id based passphrase (stable random ID).
97    /// 2. Fall back to legacy hostname-based passphrases for migration.
98    /// 3. On legacy success, re-key the keystore to the new machine-id passphrase.
99    ///
100    /// **Security note:** This provides convenience-only protection. The passphrase
101    /// file is readable by any local process. Use `unlock()` with a user-supplied
102    /// passphrase for secrets requiring real confidentiality.
103    pub fn unlock_machine(&self) -> Result<()> {
104        // Try the current machine-id passphrase first.
105        let primary = machine_passphrase();
106        if self.unlock(&primary).is_ok() {
107            return Ok(());
108        }
109
110        // Try all legacy hostname-based passphrases (pre-rebrand ironclad-*,
111        // post-rebrand roboticus-*, env vars, gethostname syscall).
112        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                // Auto-migrate: re-key to the stable machine-id passphrase so
116                // future unlocks don't depend on hostname stability.
117                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        // All strategies failed — return the error from the primary attempt.
127        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    /// Re-encrypt with a new passphrase. Must already be unlocked.
271    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 lock before filesystem I/O to avoid holding it during blocking ops.
387        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
484/// Redact a key name for audit logging: show the first 3 characters followed
485/// by `***` so that logs are useful for debugging without exposing full names.
486fn redact_key_name(key: &str) -> String {
487    let visible: String = key.chars().take(3).collect();
488    format!("{visible}***")
489}
490
491/// Returns the path to the persistent machine ID file.
492fn machine_id_path() -> PathBuf {
493    crate::home_dir().join(".roboticus").join("machine-id")
494}
495
496/// Read or create a stable machine ID.
497///
498/// The machine ID is a random hex string generated on first use and persisted
499/// at `~/.roboticus/machine-id`. Unlike hostname-based derivation, this value
500/// never changes across network contexts, DHCP leases, machine renames, or
501/// shell environments.
502///
503/// **Security note:** This provides convenience-only protection (same as the
504/// old hostname-based scheme). The passphrase is readable by any local process.
505/// Use `Keystore::unlock()` with a user-supplied passphrase for real confidentiality.
506fn 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
522/// Generate a new random machine ID and persist it to disk.
523fn 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
544/// All legacy passphrase derivations to try for backward-compatible unlock.
545/// Returns a list of candidate passphrases covering:
546/// - Pre-rebrand (`ironclad-machine-key:`) with env vars
547/// - Pre-rebrand with gethostname syscall
548/// - Post-rebrand (`roboticus-machine-key:`) with env vars
549/// - Post-rebrand with gethostname syscall
550fn 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    // Pre-rebrand prefix (the original key format)
564    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    // Post-rebrand prefix
572    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        // Simulate a second process mutating the same keystore file.
725        let ks_b = Keystore::new(&path);
726        ks_b.unlock_machine().unwrap();
727        ks_b.set("openai_api_key", "new-value").unwrap();
728
729        // Without a refresh-on-read policy this can stay stale in ks_a.
730        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        // Key names are redacted: only first 3 chars visible, followed by ***
780        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        // Don't unlock
797        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        // Write too-short data (less than SALT_LEN + NONCE_LEN + 1)
847        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        // machine_passphrase creates ~/.roboticus/machine-id on first call;
933        // subsequent calls must return the same value.
934        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); // 32 bytes = 64 hex chars
941    }
942
943    #[test]
944    fn legacy_passphrases_include_both_prefixes() {
945        let candidates = legacy_passphrases();
946        assert!(!candidates.is_empty());
947        // Must include at least the ironclad prefix (pre-rebrand)
948        assert!(
949            candidates
950                .iter()
951                .any(|p| p.starts_with("ironclad-machine-key:"))
952        );
953        // Must include at least the roboticus prefix (post-rebrand)
954        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        // Simulate a keystore created with a legacy passphrase.
964        let path = temp_path();
965        let candidates = legacy_passphrases();
966        let legacy_pass = &candidates[0]; // first legacy candidate
967        let ks = Keystore::new(&path);
968        ks.unlock(legacy_pass).unwrap();
969        ks.set("secret", "migrated").unwrap();
970        drop(ks);
971
972        // unlock_machine should find it via legacy fallback and auto-rekey.
973        let ks2 = Keystore::new(&path);
974        ks2.unlock_machine().unwrap();
975        assert_eq!(ks2.get("secret"), Some("migrated".into()));
976
977        // After migration, the primary (machine-id) passphrase should work directly.
978        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        // Simulate a keystore created with the ironclad prefix (pre-rebrand).
988        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        // unlock_machine must recover it.
1003        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            // Both threads must complete (no deadlock) while keeping this test fast
1044            // enough for CI and local release gates.
1045            h1.join().unwrap();
1046            h2.join().unwrap();
1047        });
1048    }
1049}