Skip to main content

zeroclaw_config/
secrets.rs

1// Encrypted secret store — defense-in-depth for API keys and tokens.
2//
3// Secrets are encrypted using ChaCha20-Poly1305 AEAD with a random key stored
4// in `~/.zeroclaw/.secret_key` with restrictive file permissions (0600). The
5// config file stores only hex-encoded ciphertext, never plaintext keys.
6//
7// Each encryption generates a fresh random 12-byte nonce, prepended to the
8// ciphertext. The Poly1305 authentication tag prevents tampering.
9//
10// This prevents:
11//   - Plaintext exposure in config files
12//   - Casual `grep` or `git log` leaks
13//   - Accidental commit of raw API keys
14//   - Known-plaintext attacks (unlike the previous XOR cipher)
15//   - Ciphertext tampering (authenticated encryption)
16//
17// For sovereign users who prefer plaintext, `secrets.encrypt = false` disables this.
18//
19// Migration: values with the legacy `enc:` prefix (XOR cipher) are decrypted
20// using the old algorithm for backward compatibility. New encryptions always
21// produce `enc2:` (ChaCha20-Poly1305).
22
23use anyhow::{Context, Result};
24use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
25use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Key, Nonce};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29/// Length of the random encryption key in bytes (256-bit, matches `ChaCha20`).
30#[cfg(test)]
31const KEY_LEN: usize = 32;
32
33/// ChaCha20-Poly1305 nonce length in bytes.
34const NONCE_LEN: usize = 12;
35
36/// Manages encrypted storage of secrets (API keys, tokens, etc.)
37#[derive(Debug, Clone)]
38pub struct SecretStore {
39    /// Path to the key file (`~/.zeroclaw/.secret_key`)
40    key_path: PathBuf,
41    /// Whether encryption is enabled
42    enabled: bool,
43}
44
45impl SecretStore {
46    /// Create a new secret store rooted at the given directory.
47    pub fn new(zeroclaw_dir: &Path, enabled: bool) -> Self {
48        Self {
49            key_path: zeroclaw_dir.join(".secret_key"),
50            enabled,
51        }
52    }
53
54    /// Encrypt a plaintext secret. Returns hex-encoded ciphertext prefixed with `enc2:`.
55    /// Format: `enc2:<hex(nonce ‖ ciphertext ‖ tag)>` (12 + N + 16 bytes).
56    /// If encryption is disabled, returns the plaintext as-is.
57    pub fn encrypt(&self, plaintext: &str) -> Result<String> {
58        if !self.enabled || plaintext.is_empty() {
59            return Ok(plaintext.to_string());
60        }
61
62        let key_bytes = self.load_or_create_key()?;
63        let key = Key::from_slice(&key_bytes);
64        let cipher = ChaCha20Poly1305::new(key);
65
66        let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
67        let ciphertext = cipher.encrypt(&nonce, plaintext.as_bytes()).map_err(|e| {
68            ::zeroclaw_log::record!(
69                ERROR,
70                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
71                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
72                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
73                "ChaCha20-Poly1305 encryption failed"
74            );
75            anyhow::Error::msg(format!("Encryption failed: {e}"))
76        })?;
77
78        // Prepend nonce to ciphertext for storage
79        let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
80        blob.extend_from_slice(&nonce);
81        blob.extend_from_slice(&ciphertext);
82
83        Ok(format!("enc2:{}", hex_encode(&blob)))
84    }
85
86    /// Decrypt a secret.
87    /// - `enc2:` prefix → ChaCha20-Poly1305 (current format)
88    /// - `enc:` prefix → legacy XOR cipher (backward compatibility for migration)
89    /// - No prefix → returned as-is (plaintext config)
90    ///
91    /// **Warning**: Legacy `enc:` values are insecure. Use `decrypt_and_migrate` to
92    /// automatically upgrade them to the secure `enc2:` format.
93    pub fn decrypt(&self, value: &str) -> Result<String> {
94        if let Some(hex_str) = value.strip_prefix("enc2:") {
95            self.decrypt_chacha20(hex_str)
96        } else if let Some(hex_str) = value.strip_prefix("enc:") {
97            self.decrypt_legacy_xor(hex_str)
98        } else {
99            Ok(value.to_string())
100        }
101    }
102
103    /// Decrypt a secret and return a migrated `enc2:` value if the input used legacy `enc:` format.
104    ///
105    /// Returns `(plaintext, Some(new_enc2_value))` if migration occurred, or
106    /// `(plaintext, None)` if no migration was needed.
107    ///
108    /// This allows callers to persist the upgraded value back to config.
109    pub fn decrypt_and_migrate(&self, value: &str) -> Result<(String, Option<String>)> {
110        if let Some(hex_str) = value.strip_prefix("enc2:") {
111            // Already using secure format — no migration needed
112            let plaintext = self.decrypt_chacha20(hex_str)?;
113            Ok((plaintext, None))
114        } else if let Some(hex_str) = value.strip_prefix("enc:") {
115            // Legacy XOR cipher — decrypt and re-encrypt with ChaCha20-Poly1305
116            ::zeroclaw_log::record!(
117                WARN,
118                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
119                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
120                "Decrypting legacy XOR-encrypted secret (enc: prefix). \
121                 This format is insecure and will be removed in a future release. \
122                 The secret will be automatically migrated to enc2: (ChaCha20-Poly1305)."
123            );
124            let plaintext = self.decrypt_legacy_xor(hex_str)?;
125            let migrated = self.encrypt(&plaintext)?;
126            Ok((plaintext, Some(migrated)))
127        } else {
128            // Plaintext — no migration needed
129            Ok((value.to_string(), None))
130        }
131    }
132
133    /// Check if a value uses the legacy `enc:` format that should be migrated.
134    pub fn needs_migration(value: &str) -> bool {
135        value.starts_with("enc:")
136    }
137
138    /// Decrypt using ChaCha20-Poly1305 (current secure format).
139    fn decrypt_chacha20(&self, hex_str: &str) -> Result<String> {
140        let blob =
141            hex_decode(hex_str).context("Failed to decode encrypted secret (corrupt hex)")?;
142        anyhow::ensure!(
143            blob.len() > NONCE_LEN,
144            "Encrypted value too short (missing nonce)"
145        );
146
147        let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
148        let nonce = Nonce::from_slice(nonce_bytes);
149        let key_bytes = self.load_or_create_key()?;
150        let key = Key::from_slice(&key_bytes);
151        let cipher = ChaCha20Poly1305::new(key);
152
153        let plaintext_bytes = cipher
154            .decrypt(nonce, ciphertext)
155            .map_err(|_| {
156                ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"key_path": self.key_path.display().to_string()})), "enc2: decryption failed. `.secret_key` is missing or does not match the key used to encrypt this value. \
157                     Common cause: volume wipe, container migration, or backup-restore where `.secret_key` was not preserved alongside `config.toml`. \
158                     Restore the original `.secret_key` from backup, or re-encrypt the affected secrets via `zeroclaw onboard`.");
159                anyhow::Error::msg(
160                    "enc2: decryption failed (wrong `.secret_key` or tampered ciphertext)"
161                )
162            })?;
163
164        String::from_utf8(plaintext_bytes)
165            .context("Decrypted secret is not valid UTF-8 — corrupt data")
166    }
167
168    /// Decrypt using legacy XOR cipher (insecure, for backward compatibility only).
169    fn decrypt_legacy_xor(&self, hex_str: &str) -> Result<String> {
170        let ciphertext = hex_decode(hex_str)
171            .context("Failed to decode legacy encrypted secret (corrupt hex)")?;
172        let key = self.load_or_create_key()?;
173        let plaintext_bytes = xor_cipher(&ciphertext, &key);
174        String::from_utf8(plaintext_bytes)
175            .context("Decrypted legacy secret is not valid UTF-8 — wrong key or corrupt data")
176    }
177
178    /// Check if a value is already encrypted (current or legacy format).
179    pub fn is_encrypted(value: &str) -> bool {
180        value.starts_with("enc2:") || value.starts_with("enc:")
181    }
182
183    /// Check if a value uses the secure `enc2:` format.
184    pub fn is_secure_encrypted(value: &str) -> bool {
185        value.starts_with("enc2:")
186    }
187
188    /// Load the encryption key from disk, or create one if it doesn't exist.
189    fn load_or_create_key(&self) -> Result<Vec<u8>> {
190        if self.key_path.exists() {
191            let hex_key =
192                fs::read_to_string(&self.key_path).context("Failed to read secret key file")?;
193            hex_decode(hex_key.trim()).context("Secret key file is corrupt")
194        } else {
195            let key = generate_random_key();
196            if let Some(parent) = self.key_path.parent() {
197                fs::create_dir_all(parent)?;
198            }
199            fs::write(&self.key_path, hex_encode(&key))
200                .context("Failed to write secret key file")?;
201
202            // Set restrictive permissions
203            #[cfg(unix)]
204            {
205                use std::os::unix::fs::PermissionsExt;
206                fs::set_permissions(&self.key_path, fs::Permissions::from_mode(0o600))
207                    .context("Failed to set key file permissions")?;
208            }
209            #[cfg(windows)]
210            {
211                // On Windows, use icacls to restrict permissions to current user only
212                // Use whoami command to get full user identity (COMPUTER\User or DOMAIN\User)
213                // which is required by icacls for correct parsing
214                let username = std::process::Command::new("whoami")
215                    .output()
216                    .ok()
217                    .filter(|o| o.status.success())
218                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
219                    .unwrap_or_else(|| std::env::var("USERNAME").unwrap_or_default());
220                let Some(grant_arg) = build_windows_icacls_grant_arg(&username) else {
221                    ::zeroclaw_log::record!(
222                        WARN,
223                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
224                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
225                        "USERNAME environment variable is empty; \
226                         cannot restrict key file permissions via icacls"
227                    );
228                    return Ok(key);
229                };
230
231                // First, ensure the current user owns the file. Without this,
232                // Windows may assign an invalid SID as owner, making the file
233                // unreadable for subsequent commands. (See issue #4532.)
234                match std::process::Command::new("takeown")
235                    .arg("/F")
236                    .arg(&self.key_path)
237                    .output()
238                {
239                    Ok(o) if !o.status.success() => {
240                        ::zeroclaw_log::record!(
241                            WARN,
242                            ::zeroclaw_log::Event::new(
243                                module_path!(),
244                                ::zeroclaw_log::Action::Note
245                            )
246                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
247                            &format!(
248                                "Failed to take ownership of key file via takeown (exit code {:?})",
249                                o.status.code()
250                            )
251                        );
252                    }
253                    Err(e) => {
254                        ::zeroclaw_log::record!(
255                            WARN,
256                            ::zeroclaw_log::Event::new(
257                                module_path!(),
258                                ::zeroclaw_log::Action::Note
259                            )
260                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
261                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
262                            "Could not take ownership of key file"
263                        );
264                    }
265                    _ => {
266                        ::zeroclaw_log::record!(
267                            DEBUG,
268                            ::zeroclaw_log::Event::new(
269                                module_path!(),
270                                ::zeroclaw_log::Action::Note
271                            ),
272                            "Key file ownership set to current user via takeown"
273                        );
274                    }
275                }
276
277                match std::process::Command::new("icacls")
278                    .arg(&self.key_path)
279                    .args(["/inheritance:r", "/grant:r"])
280                    .arg(grant_arg)
281                    .output()
282                {
283                    Ok(o) if !o.status.success() => {
284                        ::zeroclaw_log::record!(
285                            WARN,
286                            ::zeroclaw_log::Event::new(
287                                module_path!(),
288                                ::zeroclaw_log::Action::Note
289                            )
290                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
291                            &format!(
292                                "Failed to set key file permissions via icacls (exit code {:?})",
293                                o.status.code()
294                            )
295                        );
296                    }
297                    Err(e) => {
298                        ::zeroclaw_log::record!(
299                            WARN,
300                            ::zeroclaw_log::Event::new(
301                                module_path!(),
302                                ::zeroclaw_log::Action::Note
303                            )
304                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
305                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
306                            "Could not set key file permissions"
307                        );
308                    }
309                    _ => {
310                        ::zeroclaw_log::record!(
311                            DEBUG,
312                            ::zeroclaw_log::Event::new(
313                                module_path!(),
314                                ::zeroclaw_log::Action::Note
315                            ),
316                            "Key file permissions restricted via icacls"
317                        );
318                    }
319                }
320            }
321
322            Ok(key)
323        }
324    }
325}
326
327/// XOR cipher with repeating key. Same function for encrypt and decrypt.
328fn xor_cipher(data: &[u8], key: &[u8]) -> Vec<u8> {
329    if key.is_empty() {
330        return data.to_vec();
331    }
332    data.iter()
333        .enumerate()
334        .map(|(i, &b)| b ^ key[i % key.len()])
335        .collect()
336}
337
338/// Generate a random 256-bit key using the OS CSPRNG.
339///
340/// Uses `OsRng` (via `getrandom`) directly, providing full 256-bit entropy
341/// without the fixed version/variant bits that UUID v4 introduces.
342fn generate_random_key() -> Vec<u8> {
343    ChaCha20Poly1305::generate_key(&mut OsRng).to_vec()
344}
345
346/// Hex-encode bytes to a lowercase hex string.
347fn hex_encode(data: &[u8]) -> String {
348    let mut s = String::with_capacity(data.len() * 2);
349    for b in data {
350        use std::fmt::Write;
351        let _ = write!(s, "{b:02x}");
352    }
353    s
354}
355
356/// Build the `/grant` argument for `icacls` using a normalized username.
357/// Returns `None` when the username is empty or whitespace-only.
358#[cfg(any(windows, test))]
359fn build_windows_icacls_grant_arg(username: &str) -> Option<String> {
360    let normalized = username.trim();
361    if normalized.is_empty() {
362        return None;
363    }
364    Some(format!("{normalized}:F"))
365}
366
367/// Hex-decode a hex string to bytes.
368#[allow(clippy::manual_is_multiple_of)]
369fn hex_decode(hex: &str) -> Result<Vec<u8>> {
370    if (hex.len() & 1) != 0 {
371        anyhow::bail!("Hex string has odd length");
372    }
373    (0..hex.len())
374        .step_by(2)
375        .map(|i| {
376            u8::from_str_radix(&hex[i..i + 2], 16)
377                .map_err(|e| anyhow::Error::msg(format!("Invalid hex at position {i}: {e}")))
378        })
379        .collect()
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use tempfile::TempDir;
386
387    // ── SecretStore basics ─────────────────────────────────────
388
389    #[test]
390    fn encrypt_decrypt_roundtrip() {
391        let tmp = TempDir::new().unwrap();
392        let store = SecretStore::new(tmp.path(), true);
393        let secret = "sk-my-secret-api-key-12345";
394
395        let encrypted = store.encrypt(secret).unwrap();
396        assert!(encrypted.starts_with("enc2:"), "Should have enc2: prefix");
397        assert_ne!(encrypted, secret, "Should not be plaintext");
398
399        let decrypted = store.decrypt(&encrypted).unwrap();
400        assert_eq!(decrypted, secret, "Roundtrip must preserve original");
401    }
402
403    #[test]
404    fn encrypt_empty_returns_empty() {
405        let tmp = TempDir::new().unwrap();
406        let store = SecretStore::new(tmp.path(), true);
407        let result = store.encrypt("").unwrap();
408        assert_eq!(result, "");
409    }
410
411    #[test]
412    fn decrypt_plaintext_passthrough() {
413        let tmp = TempDir::new().unwrap();
414        let store = SecretStore::new(tmp.path(), true);
415        // Values without "enc:"/"enc2:" prefix are returned as-is (backward compat)
416        let result = store.decrypt("sk-plaintext-key").unwrap();
417        assert_eq!(result, "sk-plaintext-key");
418    }
419
420    #[test]
421    fn disabled_store_returns_plaintext() {
422        let tmp = TempDir::new().unwrap();
423        let store = SecretStore::new(tmp.path(), false);
424        let result = store.encrypt("sk-secret").unwrap();
425        assert_eq!(result, "sk-secret", "Disabled store should not encrypt");
426    }
427
428    #[test]
429    fn is_encrypted_detects_prefix() {
430        assert!(SecretStore::is_encrypted("enc2:aabbcc"));
431        assert!(SecretStore::is_encrypted("enc:aabbcc")); // legacy
432        assert!(!SecretStore::is_encrypted("sk-plaintext"));
433        assert!(!SecretStore::is_encrypted(""));
434    }
435
436    #[tokio::test]
437    async fn key_file_created_on_first_encrypt() {
438        let tmp = TempDir::new().unwrap();
439        let store = SecretStore::new(tmp.path(), true);
440        assert!(!store.key_path.exists());
441
442        store.encrypt("test").unwrap();
443        assert!(store.key_path.exists(), "Key file should be created");
444
445        let key_hex = tokio::fs::read_to_string(&store.key_path).await.unwrap();
446        assert_eq!(
447            key_hex.len(),
448            KEY_LEN * 2,
449            "Key should be {KEY_LEN} bytes hex-encoded"
450        );
451    }
452
453    #[test]
454    fn encrypting_same_value_produces_different_ciphertext() {
455        let tmp = TempDir::new().unwrap();
456        let store = SecretStore::new(tmp.path(), true);
457
458        let e1 = store.encrypt("secret").unwrap();
459        let e2 = store.encrypt("secret").unwrap();
460        assert_ne!(
461            e1, e2,
462            "AEAD with random nonce should produce different ciphertext each time"
463        );
464
465        // Both should still decrypt to the same value
466        assert_eq!(store.decrypt(&e1).unwrap(), "secret");
467        assert_eq!(store.decrypt(&e2).unwrap(), "secret");
468    }
469
470    #[test]
471    fn different_stores_same_dir_interop() {
472        let tmp = TempDir::new().unwrap();
473        let store1 = SecretStore::new(tmp.path(), true);
474        let store2 = SecretStore::new(tmp.path(), true);
475
476        let encrypted = store1.encrypt("cross-store-secret").unwrap();
477        let decrypted = store2.decrypt(&encrypted).unwrap();
478        assert_eq!(decrypted, "cross-store-secret");
479    }
480
481    #[test]
482    fn unicode_secret_roundtrip() {
483        let tmp = TempDir::new().unwrap();
484        let store = SecretStore::new(tmp.path(), true);
485        let secret = "sk-日本語テスト-émojis-🦀";
486
487        let encrypted = store.encrypt(secret).unwrap();
488        let decrypted = store.decrypt(&encrypted).unwrap();
489        assert_eq!(decrypted, secret);
490    }
491
492    #[test]
493    fn long_secret_roundtrip() {
494        let tmp = TempDir::new().unwrap();
495        let store = SecretStore::new(tmp.path(), true);
496        let secret = "a".repeat(10_000);
497
498        let encrypted = store.encrypt(&secret).unwrap();
499        let decrypted = store.decrypt(&encrypted).unwrap();
500        assert_eq!(decrypted, secret);
501    }
502
503    #[test]
504    fn corrupt_hex_returns_error() {
505        let tmp = TempDir::new().unwrap();
506        let store = SecretStore::new(tmp.path(), true);
507        let result = store.decrypt("enc2:not-valid-hex!!");
508        assert!(result.is_err());
509    }
510
511    #[test]
512    fn tampered_ciphertext_detected() {
513        let tmp = TempDir::new().unwrap();
514        let store = SecretStore::new(tmp.path(), true);
515        let encrypted = store.encrypt("sensitive-data").unwrap();
516
517        // Flip a bit in the ciphertext (after the "enc2:" prefix)
518        let hex_str = &encrypted[5..];
519        let mut blob = hex_decode(hex_str).unwrap();
520        // Modify a byte in the ciphertext portion (after the 12-byte nonce)
521        if blob.len() > NONCE_LEN {
522            blob[NONCE_LEN] ^= 0xff;
523        }
524        let tampered = format!("enc2:{}", hex_encode(&blob));
525
526        let result = store.decrypt(&tampered);
527        assert!(result.is_err(), "Tampered ciphertext must be rejected");
528    }
529
530    #[test]
531    fn wrong_key_detected() {
532        let tmp1 = TempDir::new().unwrap();
533        let tmp2 = TempDir::new().unwrap();
534        let store1 = SecretStore::new(tmp1.path(), true);
535        let store2 = SecretStore::new(tmp2.path(), true);
536
537        let encrypted = store1.encrypt("secret-for-store1").unwrap();
538        let result = store2.decrypt(&encrypted);
539        assert!(result.is_err(), "Decrypting with a different key must fail");
540    }
541
542    #[test]
543    fn decrypt_error_message_mentions_secret_key() {
544        // Operators hitting a missing or mismatched `.secret_key` (volume wipe,
545        // container migration, backup-restore without the key file) need the
546        // error message to point at the root cause. Otherwise the failure
547        // cascades into a misleading "All providers/models failed" message
548        // with no diagnostic for the underlying decrypt failure.
549        let tmp1 = TempDir::new().unwrap();
550        let tmp2 = TempDir::new().unwrap();
551        let store1 = SecretStore::new(tmp1.path(), true);
552        let store2 = SecretStore::new(tmp2.path(), true);
553
554        let encrypted = store1.encrypt("secret-for-store1").unwrap();
555        let err = store2.decrypt(&encrypted).expect_err("wrong key must fail");
556        let msg = err.to_string();
557        assert!(
558            msg.contains(".secret_key"),
559            "decrypt error must mention `.secret_key` so operators can diagnose missing/mismatched keys: got {msg:?}"
560        );
561    }
562
563    #[test]
564    fn truncated_ciphertext_returns_error() {
565        let tmp = TempDir::new().unwrap();
566        let store = SecretStore::new(tmp.path(), true);
567        // Only a few bytes — shorter than nonce
568        let result = store.decrypt("enc2:aabbccdd");
569        assert!(result.is_err(), "Too-short ciphertext must be rejected");
570    }
571
572    // ── Legacy XOR backward compatibility ───────────────────────
573
574    #[test]
575    fn legacy_xor_decrypt_still_works() {
576        let tmp = TempDir::new().unwrap();
577        let store = SecretStore::new(tmp.path(), true);
578
579        // Trigger key creation via an encrypt call
580        let _ = store.encrypt("setup").unwrap();
581        let key = store.load_or_create_key().unwrap();
582
583        // Manually produce a legacy XOR-encrypted value
584        let plaintext = "sk-legacy-api-key";
585        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
586        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
587
588        // Store should still be able to decrypt legacy values
589        let decrypted = store.decrypt(&legacy_value).unwrap();
590        assert_eq!(decrypted, plaintext, "Legacy XOR values must still decrypt");
591    }
592
593    // ── Migration tests ─────────────────────────────────────────
594
595    #[test]
596    fn needs_migration_detects_legacy_prefix() {
597        assert!(SecretStore::needs_migration("enc:aabbcc"));
598        assert!(!SecretStore::needs_migration("enc2:aabbcc"));
599        assert!(!SecretStore::needs_migration("sk-plaintext"));
600        assert!(!SecretStore::needs_migration(""));
601    }
602
603    #[test]
604    fn is_secure_encrypted_detects_enc2_only() {
605        assert!(SecretStore::is_secure_encrypted("enc2:aabbcc"));
606        assert!(!SecretStore::is_secure_encrypted("enc:aabbcc"));
607        assert!(!SecretStore::is_secure_encrypted("sk-plaintext"));
608        assert!(!SecretStore::is_secure_encrypted(""));
609    }
610
611    #[test]
612    fn decrypt_and_migrate_returns_none_for_enc2() {
613        let tmp = TempDir::new().unwrap();
614        let store = SecretStore::new(tmp.path(), true);
615
616        let encrypted = store.encrypt("my-secret").unwrap();
617        assert!(encrypted.starts_with("enc2:"));
618
619        let (plaintext, migrated) = store.decrypt_and_migrate(&encrypted).unwrap();
620        assert_eq!(plaintext, "my-secret");
621        assert!(
622            migrated.is_none(),
623            "enc2: values should not trigger migration"
624        );
625    }
626
627    #[test]
628    fn decrypt_and_migrate_returns_none_for_plaintext() {
629        let tmp = TempDir::new().unwrap();
630        let store = SecretStore::new(tmp.path(), true);
631
632        let (plaintext, migrated) = store.decrypt_and_migrate("sk-plaintext-key").unwrap();
633        assert_eq!(plaintext, "sk-plaintext-key");
634        assert!(
635            migrated.is_none(),
636            "Plaintext values should not trigger migration"
637        );
638    }
639
640    #[test]
641    fn decrypt_and_migrate_upgrades_legacy_xor() {
642        let tmp = TempDir::new().unwrap();
643        let store = SecretStore::new(tmp.path(), true);
644
645        // Create key first
646        let _ = store.encrypt("setup").unwrap();
647        let key = store.load_or_create_key().unwrap();
648
649        // Manually create a legacy XOR-encrypted value
650        let plaintext = "sk-legacy-secret-to-migrate";
651        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
652        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
653
654        // Verify it needs migration
655        assert!(SecretStore::needs_migration(&legacy_value));
656
657        // Decrypt and migrate
658        let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
659        assert_eq!(decrypted, plaintext, "Plaintext must match original");
660        assert!(migrated.is_some(), "Legacy value should trigger migration");
661
662        let new_value = migrated.unwrap();
663        assert!(
664            new_value.starts_with("enc2:"),
665            "Migrated value must use enc2: prefix"
666        );
667        assert!(
668            !SecretStore::needs_migration(&new_value),
669            "Migrated value should not need migration"
670        );
671
672        // Verify the migrated value decrypts correctly
673        let (decrypted2, migrated2) = store.decrypt_and_migrate(&new_value).unwrap();
674        assert_eq!(
675            decrypted2, plaintext,
676            "Migrated value must decrypt to same plaintext"
677        );
678        assert!(
679            migrated2.is_none(),
680            "Migrated value should not trigger another migration"
681        );
682    }
683
684    #[test]
685    fn decrypt_and_migrate_handles_unicode() {
686        let tmp = TempDir::new().unwrap();
687        let store = SecretStore::new(tmp.path(), true);
688
689        let _ = store.encrypt("setup").unwrap();
690        let key = store.load_or_create_key().unwrap();
691
692        let plaintext = "sk-日本語-émojis-🦀-тест";
693        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
694        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
695
696        let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
697        assert_eq!(decrypted, plaintext);
698        assert!(migrated.is_some());
699
700        // Verify migrated value works
701        let new_value = migrated.unwrap();
702        let (decrypted2, _) = store.decrypt_and_migrate(&new_value).unwrap();
703        assert_eq!(decrypted2, plaintext);
704    }
705
706    #[test]
707    fn decrypt_and_migrate_handles_empty_secret() {
708        let tmp = TempDir::new().unwrap();
709        let store = SecretStore::new(tmp.path(), true);
710
711        let _ = store.encrypt("setup").unwrap();
712        let key = store.load_or_create_key().unwrap();
713
714        // Empty plaintext XOR-encrypted
715        let plaintext = "";
716        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
717        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
718
719        let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
720        assert_eq!(decrypted, plaintext);
721        // Empty string encryption returns empty string (not enc2:)
722        assert!(migrated.is_some());
723        assert_eq!(migrated.unwrap(), "");
724    }
725
726    #[test]
727    fn decrypt_and_migrate_handles_long_secret() {
728        let tmp = TempDir::new().unwrap();
729        let store = SecretStore::new(tmp.path(), true);
730
731        let _ = store.encrypt("setup").unwrap();
732        let key = store.load_or_create_key().unwrap();
733
734        let plaintext = "a".repeat(10_000);
735        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
736        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
737
738        let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
739        assert_eq!(decrypted, plaintext);
740        assert!(migrated.is_some());
741
742        let new_value = migrated.unwrap();
743        let (decrypted2, _) = store.decrypt_and_migrate(&new_value).unwrap();
744        assert_eq!(decrypted2, plaintext);
745    }
746
747    #[test]
748    fn decrypt_and_migrate_fails_on_corrupt_legacy_hex() {
749        let tmp = TempDir::new().unwrap();
750        let store = SecretStore::new(tmp.path(), true);
751        let _ = store.encrypt("setup").unwrap();
752
753        let result = store.decrypt_and_migrate("enc:not-valid-hex!!");
754        assert!(result.is_err(), "Corrupt hex should fail");
755    }
756
757    #[test]
758    fn decrypt_and_migrate_wrong_key_produces_garbage_or_fails() {
759        let tmp1 = TempDir::new().unwrap();
760        let tmp2 = TempDir::new().unwrap();
761        let store1 = SecretStore::new(tmp1.path(), true);
762        let store2 = SecretStore::new(tmp2.path(), true);
763
764        // Create keys for both stores
765        let _ = store1.encrypt("setup").unwrap();
766        let _ = store2.encrypt("setup").unwrap();
767        let key1 = store1.load_or_create_key().unwrap();
768
769        // Encrypt with store1's key
770        let plaintext = "secret-for-store1";
771        let ciphertext = xor_cipher(plaintext.as_bytes(), &key1);
772        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
773
774        // Decrypt with store2 — XOR will produce garbage bytes
775        // This may fail with UTF-8 error or succeed with garbage plaintext
776        match store2.decrypt_and_migrate(&legacy_value) {
777            Ok((decrypted, _)) => {
778                // If it succeeds, the plaintext should be garbage (not the original)
779                assert_ne!(
780                    decrypted, plaintext,
781                    "Wrong key should produce garbage plaintext"
782                );
783            }
784            Err(e) => {
785                // Expected: UTF-8 decoding failure from garbage bytes
786                assert!(
787                    e.to_string().contains("UTF-8"),
788                    "Error should be UTF-8 related: {e}"
789                );
790            }
791        }
792    }
793
794    #[test]
795    fn migration_produces_different_ciphertext_each_time() {
796        let tmp = TempDir::new().unwrap();
797        let store = SecretStore::new(tmp.path(), true);
798
799        let _ = store.encrypt("setup").unwrap();
800        let key = store.load_or_create_key().unwrap();
801
802        let plaintext = "sk-same-secret";
803        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
804        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
805
806        let (_, migrated1) = store.decrypt_and_migrate(&legacy_value).unwrap();
807        let (_, migrated2) = store.decrypt_and_migrate(&legacy_value).unwrap();
808
809        assert!(migrated1.is_some());
810        assert!(migrated2.is_some());
811        assert_ne!(
812            migrated1.unwrap(),
813            migrated2.unwrap(),
814            "Each migration should produce different ciphertext (random nonce)"
815        );
816    }
817
818    #[test]
819    fn migrated_value_is_tamper_resistant() {
820        let tmp = TempDir::new().unwrap();
821        let store = SecretStore::new(tmp.path(), true);
822
823        let _ = store.encrypt("setup").unwrap();
824        let key = store.load_or_create_key().unwrap();
825
826        let plaintext = "sk-sensitive-data";
827        let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
828        let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
829
830        let (_, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
831        let new_value = migrated.unwrap();
832
833        // Tamper with the migrated value
834        let hex_str = &new_value[5..];
835        let mut blob = hex_decode(hex_str).unwrap();
836        if blob.len() > NONCE_LEN {
837            blob[NONCE_LEN] ^= 0xff;
838        }
839        let tampered = format!("enc2:{}", hex_encode(&blob));
840
841        let result = store.decrypt_and_migrate(&tampered);
842        assert!(result.is_err(), "Tampered migrated value must be rejected");
843    }
844
845    // ── Low-level helpers ───────────────────────────────────────
846
847    #[test]
848    fn xor_cipher_roundtrip() {
849        let key = b"testkey123";
850        let data = b"hello world";
851        let encrypted = xor_cipher(data, key);
852        let decrypted = xor_cipher(&encrypted, key);
853        assert_eq!(decrypted, data);
854    }
855
856    #[test]
857    fn xor_cipher_empty_key() {
858        let data = b"passthrough";
859        let result = xor_cipher(data, &[]);
860        assert_eq!(result, data);
861    }
862
863    #[test]
864    fn hex_roundtrip() {
865        let data = vec![0x00, 0x01, 0xfe, 0xff, 0xab, 0xcd];
866        let encoded = hex_encode(&data);
867        assert_eq!(encoded, "0001feffabcd");
868        let decoded = hex_decode(&encoded).unwrap();
869        assert_eq!(decoded, data);
870    }
871
872    #[test]
873    fn hex_decode_odd_length_fails() {
874        assert!(hex_decode("abc").is_err());
875    }
876
877    #[test]
878    fn hex_decode_invalid_chars_fails() {
879        assert!(hex_decode("zzzz").is_err());
880    }
881
882    #[test]
883    fn windows_icacls_grant_arg_rejects_empty_username() {
884        assert_eq!(build_windows_icacls_grant_arg(""), None);
885        assert_eq!(build_windows_icacls_grant_arg("   \t\n"), None);
886    }
887
888    #[test]
889    fn windows_icacls_grant_arg_trims_username() {
890        assert_eq!(
891            build_windows_icacls_grant_arg("  alice  "),
892            Some("alice:F".to_string())
893        );
894    }
895
896    #[test]
897    fn windows_icacls_grant_arg_preserves_valid_characters() {
898        assert_eq!(
899            build_windows_icacls_grant_arg("DOMAIN\\svc-user"),
900            Some("DOMAIN\\svc-user:F".to_string())
901        );
902    }
903
904    #[test]
905    fn generate_random_key_correct_length() {
906        let key = generate_random_key();
907        assert_eq!(key.len(), KEY_LEN);
908    }
909
910    #[test]
911    fn generate_random_key_not_all_zeros() {
912        let key = generate_random_key();
913        assert!(key.iter().any(|&b| b != 0), "Key should not be all zeros");
914    }
915
916    #[test]
917    fn two_random_keys_differ() {
918        let k1 = generate_random_key();
919        let k2 = generate_random_key();
920        assert_ne!(k1, k2, "Two random keys should differ");
921    }
922
923    #[test]
924    fn generate_random_key_has_no_uuid_fixed_bits() {
925        // UUID v4 has fixed bits at positions 6 (version = 0b0100xxxx) and
926        // 8 (variant = 0b10xxxxxx). A direct CSPRNG key should not consistently
927        // have these patterns across multiple samples.
928        let mut version_match = 0;
929        let mut variant_match = 0;
930        let samples = 100;
931        for _ in 0..samples {
932            let key = generate_random_key();
933            // In UUID v4, byte 6 always has top nibble = 0x4
934            if key[6] & 0xf0 == 0x40 {
935                version_match += 1;
936            }
937            // In UUID v4, byte 8 always has top 2 bits = 0b10
938            if key[8] & 0xc0 == 0x80 {
939                variant_match += 1;
940            }
941        }
942        // With true randomness, each pattern should appear ~1/16 and ~1/4 of
943        // the time. UUID would hit 100/100 on both. Allow generous margin.
944        assert!(
945            version_match < 30,
946            "byte[6] matched UUID v4 version nibble {version_match}/100 times — \
947             likely still using UUID-based key generation"
948        );
949        assert!(
950            variant_match < 50,
951            "byte[8] matched UUID v4 variant bits {variant_match}/100 times — \
952             likely still using UUID-based key generation"
953        );
954    }
955
956    #[cfg(unix)]
957    #[test]
958    fn key_file_has_restricted_permissions() {
959        use std::os::unix::fs::PermissionsExt;
960        let tmp = TempDir::new().unwrap();
961        let store = SecretStore::new(tmp.path(), true);
962        store.encrypt("trigger key creation").unwrap();
963
964        let perms = fs::metadata(&store.key_path).unwrap().permissions();
965        assert_eq!(
966            perms.mode() & 0o777,
967            0o600,
968            "Key file must be owner-only (0600)"
969        );
970    }
971
972    /// Document the expected ordering on Windows: `takeown` runs before `icacls`.
973    ///
974    /// Without `takeown`, the file owner may be an invalid SID, causing `icacls`
975    /// grants to succeed against an unowned file that later becomes unreadable.
976    /// This test verifies the code structure expectation.
977    #[test]
978    fn takeown_runs_before_icacls_on_windows() {
979        // Read the source to confirm `takeown` appears before `icacls` in the
980        // Windows cfg block of `load_or_create_key`. This is a structural
981        // documentation test — the actual commands are Windows-only.
982        let source = include_str!("secrets.rs");
983        let takeown_pos = source
984            .find("Command::new(\"takeown\")")
985            .expect("takeown call must exist in secrets.rs");
986        let icacls_pos = source
987            .find("Command::new(\"icacls\")")
988            .expect("icacls call must exist in secrets.rs");
989        assert!(
990            takeown_pos < icacls_pos,
991            "takeown must run before icacls to fix file ownership first (issue #4532)"
992        );
993    }
994}