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.
492///
493/// In test mode (`ROBOTICUS_TEST_MACHINE_ID_DIR` set), uses a temp directory
494/// to prevent tests from destroying the production keystore's encryption key.
495fn 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
502/// Read or create a stable machine ID.
503///
504/// The machine ID is a random hex string generated on first use and persisted
505/// at `~/.roboticus/machine-id`. Unlike hostname-based derivation, this value
506/// never changes across network contexts, DHCP leases, machine renames, or
507/// shell environments.
508///
509/// **Security note:** This provides convenience-only protection (same as the
510/// old hostname-based scheme). The passphrase is readable by any local process.
511/// Use `Keystore::unlock()` with a user-supplied passphrase for real confidentiality.
512fn 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
528/// Generate a new random machine ID and persist it to disk.
529fn 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
550/// All legacy passphrase derivations to try for backward-compatible unlock.
551/// Returns a list of candidate passphrases covering:
552/// - Pre-rebrand (`ironclad-machine-key:`) with env vars
553/// - Pre-rebrand with gethostname syscall
554/// - Post-rebrand (`roboticus-machine-key:`) with env vars
555/// - Post-rebrand with gethostname syscall
556fn 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    // Pre-rebrand prefix (the original key format)
570    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    // Post-rebrand prefix
578    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// ── Shared key resolution ────────────────────────────────────────────────
589//
590// Canonical resolution cascade for provider API keys. Both the API
591// (`roboticus-api/src/api/routes/admin.rs`) and the CLI
592// (`roboticus-cli/src/cli/admin/misc/helper_model_triage.rs`) must use
593// this to avoid divergence.
594
595/// Where a provider's API key was found (or that none is available).
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub enum KeySource {
598    /// Provider runs locally and needs no key.
599    NotRequired,
600    /// Key found via OAuth auth_mode.
601    OAuth,
602    /// Key found in the keystore under the given entry name.
603    Keystore(String),
604    /// Key found in the given environment variable.
605    EnvVar(String),
606    /// No key found anywhere.
607    Missing,
608}
609
610impl KeySource {
611    /// Returns a `(status, source)` pair for API/dashboard display.
612    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    /// Whether a usable key was found.
623    pub fn is_configured(&self) -> bool {
624        matches!(self, Self::Keystore(_) | Self::EnvVar(_) | Self::OAuth)
625    }
626}
627
628/// Resolve the source of a provider's API key using a priority cascade:
629///
630/// 1. `is_local` → `NotRequired`
631/// 2. `auth_mode == "oauth"` → `OAuth`
632/// 3. Explicit keystore ref (`api_key_ref = "keystore:<name>"`)
633/// 4. Conventional keystore name (`{provider_name}_api_key`)
634/// 5. Non-empty environment variable (`api_key_env`)
635/// 6. `Missing`
636///
637/// If the keystore is locked, keystore lookups are skipped and the cascade
638/// falls through to env vars or `Missing`.
639pub 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    /// Tests that call `unlock_machine()` or `machine_passphrase()` share the
686    /// global `~/.roboticus/machine-id` file. Without serialization, parallel
687    /// tests can delete/recreate the file while another test has already derived
688    /// a passphrase from the old value, causing "decryption failed" errors.
689    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        // Simulate a second process mutating the same keystore file.
831        let ks_b = Keystore::new(&path);
832        ks_b.unlock_machine().unwrap();
833        ks_b.set("openai_api_key", "new-value").unwrap();
834
835        // Without a refresh-on-read policy this can stay stale in ks_a.
836        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        // Key names are redacted: only first 3 chars visible, followed by ***
886        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        // Don't unlock
903        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        // Write too-short data (less than SALT_LEN + NONCE_LEN + 1)
953        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    /// Direct tests to a temp directory so they never touch the production
1029    /// machine-id at `~/.roboticus/machine-id`.
1030    ///
1031    /// SAFETY: set_var/remove_var are unsafe in Rust 2024 because they're
1032    /// not thread-safe. We hold MACHINE_ID_MUTEX so no concurrent access.
1033    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            // machine_passphrase creates a machine-id on first call;
1056            // subsequent calls must return the same value.
1057            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); // 32 bytes = 64 hex chars
1064        });
1065    }
1066
1067    #[test]
1068    fn legacy_passphrases_include_both_prefixes() {
1069        let candidates = legacy_passphrases();
1070        assert!(!candidates.is_empty());
1071        // Must include at least the ironclad prefix (pre-rebrand)
1072        assert!(
1073            candidates
1074                .iter()
1075                .any(|p| p.starts_with("ironclad-machine-key:"))
1076        );
1077        // Must include at least the roboticus prefix (post-rebrand)
1078        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            // Simulate a keystore created with a legacy passphrase.
1090            let path = temp_path();
1091            let candidates = legacy_passphrases();
1092            let legacy_pass = &candidates[0]; // first legacy candidate
1093            let ks = Keystore::new(&path);
1094            ks.unlock(legacy_pass).unwrap();
1095            ks.set("secret", "migrated").unwrap();
1096            drop(ks);
1097
1098            // unlock_machine should find it via legacy fallback and auto-rekey.
1099            let ks2 = Keystore::new(&path);
1100            ks2.unlock_machine().unwrap();
1101            assert_eq!(ks2.get("secret"), Some("migrated".into()));
1102
1103            // After migration, the primary (machine-id) passphrase should work directly.
1104            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        // Simulate a keystore created with the ironclad prefix (pre-rebrand).
1116        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        // unlock_machine must recover it.
1131        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            // Both threads must complete (no deadlock) while keeping this test fast
1172            // enough for CI and local release gates.
1173            h1.join().unwrap();
1174            h2.join().unwrap();
1175        });
1176    }
1177}