// Encrypted secret store — defense-in-depth for API keys and tokens. // // Secrets are encrypted using ChaCha20-Poly1305 AEAD with a random key stored // in `~/.zeroclaw/.secret_key` with restrictive file permissions (0600). The // config file stores only hex-encoded ciphertext, never plaintext keys. // // Each encryption generates a fresh random 12-byte nonce, prepended to the // ciphertext. The Poly1305 authentication tag prevents tampering. // // This prevents: // - Plaintext exposure in config files // - Casual `grep` or `git log` leaks // - Accidental commit of raw API keys // - Known-plaintext attacks (unlike the previous XOR cipher) // - Ciphertext tampering (authenticated encryption) // // For sovereign users who prefer plaintext, `secrets.encrypt = false` disables this. // // Migration: values with the legacy `enc:` prefix (XOR cipher) are decrypted // using the old algorithm for backward compatibility. New encryptions always // produce `enc2:` (ChaCha20-Poly1305). use anyhow::{Context, Result}; use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Key, Nonce}; use std::fs; use std::path::{Path, PathBuf}; /// Length of the random encryption key in bytes (256-bit, matches `ChaCha20`). const KEY_LEN: usize = 32; /// ChaCha20-Poly1305 nonce length in bytes. const NONCE_LEN: usize = 12; /// Manages encrypted storage of secrets (API keys, tokens, etc.) #[derive(Debug, Clone)] pub struct SecretStore { /// Path to the key file (`~/.zeroclaw/.secret_key`) key_path: PathBuf, /// Whether encryption is enabled enabled: bool, } impl SecretStore { /// Create a new secret store rooted at the given directory. pub fn new(zeroclaw_dir: &Path, enabled: bool) -> Self { Self { key_path: zeroclaw_dir.join(".secret_key"), enabled, } } /// Encrypt a plaintext secret. Returns hex-encoded ciphertext prefixed with `enc2:`. /// Format: `enc2:` (12 + N + 16 bytes). /// If encryption is disabled, returns the plaintext as-is. pub fn encrypt(&self, plaintext: &str) -> Result { if !self.enabled || plaintext.is_empty() { return Ok(plaintext.to_string()); } let key_bytes = self.load_or_create_key()?; let key = Key::from_slice(&key_bytes); let cipher = ChaCha20Poly1305::new(key); let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) .map_err(|e| anyhow::anyhow!("Encryption failed: {e}"))?; // Prepend nonce to ciphertext for storage let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); blob.extend_from_slice(&nonce); blob.extend_from_slice(&ciphertext); Ok(format!("enc2:{}", hex_encode(&blob))) } /// Decrypt a secret. /// - `enc2:` prefix → ChaCha20-Poly1305 (current format) /// - `enc:` prefix → legacy XOR cipher (backward compatibility for migration) /// - No prefix → returned as-is (plaintext config) /// /// **Warning**: Legacy `enc:` values are insecure. Use `decrypt_and_migrate` to /// automatically upgrade them to the secure `enc2:` format. pub fn decrypt(&self, value: &str) -> Result { if let Some(hex_str) = value.strip_prefix("enc2:") { self.decrypt_chacha20(hex_str) } else if let Some(hex_str) = value.strip_prefix("enc:") { self.decrypt_legacy_xor(hex_str) } else { Ok(value.to_string()) } } /// Decrypt a secret and return a migrated `enc2:` value if the input used legacy `enc:` format. /// /// Returns `(plaintext, Some(new_enc2_value))` if migration occurred, or /// `(plaintext, None)` if no migration was needed. /// /// This allows callers to persist the upgraded value back to config. pub fn decrypt_and_migrate(&self, value: &str) -> Result<(String, Option)> { if let Some(hex_str) = value.strip_prefix("enc2:") { // Already using secure format — no migration needed let plaintext = self.decrypt_chacha20(hex_str)?; Ok((plaintext, None)) } else if let Some(hex_str) = value.strip_prefix("enc:") { // Legacy XOR cipher — decrypt and re-encrypt with ChaCha20-Poly1305 tracing::warn!( "Decrypting legacy XOR-encrypted secret (enc: prefix). \ This format is insecure and will be removed in a future release. \ The secret will be automatically migrated to enc2: (ChaCha20-Poly1305)." ); let plaintext = self.decrypt_legacy_xor(hex_str)?; let migrated = self.encrypt(&plaintext)?; Ok((plaintext, Some(migrated))) } else { // Plaintext — no migration needed Ok((value.to_string(), None)) } } /// Check if a value uses the legacy `enc:` format that should be migrated. pub fn needs_migration(value: &str) -> bool { value.starts_with("enc:") } /// Decrypt using ChaCha20-Poly1305 (current secure format). fn decrypt_chacha20(&self, hex_str: &str) -> Result { let blob = hex_decode(hex_str).context("Failed to decode encrypted secret (corrupt hex)")?; anyhow::ensure!( blob.len() > NONCE_LEN, "Encrypted value too short (missing nonce)" ); let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN); let nonce = Nonce::from_slice(nonce_bytes); let key_bytes = self.load_or_create_key()?; let key = Key::from_slice(&key_bytes); let cipher = ChaCha20Poly1305::new(key); let plaintext_bytes = cipher .decrypt(nonce, ciphertext) .map_err(|_| anyhow::anyhow!("Decryption failed — wrong key or tampered data"))?; String::from_utf8(plaintext_bytes) .context("Decrypted secret is not valid UTF-8 — corrupt data") } /// Decrypt using legacy XOR cipher (insecure, for backward compatibility only). fn decrypt_legacy_xor(&self, hex_str: &str) -> Result { let ciphertext = hex_decode(hex_str) .context("Failed to decode legacy encrypted secret (corrupt hex)")?; let key = self.load_or_create_key()?; let plaintext_bytes = xor_cipher(&ciphertext, &key); String::from_utf8(plaintext_bytes) .context("Decrypted legacy secret is not valid UTF-8 — wrong key or corrupt data") } /// Check if a value is already encrypted (current or legacy format). pub fn is_encrypted(value: &str) -> bool { value.starts_with("enc2:") || value.starts_with("enc:") } /// Check if a value uses the secure `enc2:` format. pub fn is_secure_encrypted(value: &str) -> bool { value.starts_with("enc2:") } /// Load the encryption key from disk, or create one if it doesn't exist. fn load_or_create_key(&self) -> Result> { if self.key_path.exists() { let hex_key = fs::read_to_string(&self.key_path).context("Failed to read secret key file")?; hex_decode(hex_key.trim()).context("Secret key file is corrupt") } else { let key = generate_random_key(); if let Some(parent) = self.key_path.parent() { fs::create_dir_all(parent)?; } fs::write(&self.key_path, hex_encode(&key)) .context("Failed to write secret key file")?; // Set restrictive permissions #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&self.key_path, fs::Permissions::from_mode(0o600)) .context("Failed to set key file permissions")?; } #[cfg(windows)] { // On Windows, use icacls to restrict permissions to current user only let _ = std::process::Command::new("icacls") .arg(&self.key_path) .args(["/inheritance:r", "/grant:r"]) .arg(format!( "{}:F", std::env::var("USERNAME").unwrap_or_default() )) .output(); } Ok(key) } } } /// XOR cipher with repeating key. Same function for encrypt and decrypt. fn xor_cipher(data: &[u8], key: &[u8]) -> Vec { if key.is_empty() { return data.to_vec(); } data.iter() .enumerate() .map(|(i, &b)| b ^ key[i % key.len()]) .collect() } /// Generate a random key using system entropy (UUID v4 + process ID + timestamp). fn generate_random_key() -> Vec { // Use two UUIDs (32 random bytes) as our key material let u1 = uuid::Uuid::new_v4(); let u2 = uuid::Uuid::new_v4(); let mut key = Vec::with_capacity(KEY_LEN); key.extend_from_slice(u1.as_bytes()); key.extend_from_slice(u2.as_bytes()); key.truncate(KEY_LEN); key } /// Hex-encode bytes to a lowercase hex string. fn hex_encode(data: &[u8]) -> String { let mut s = String::with_capacity(data.len() * 2); for b in data { use std::fmt::Write; let _ = write!(s, "{b:02x}"); } s } /// Hex-decode a hex string to bytes. #[allow(clippy::manual_is_multiple_of)] fn hex_decode(hex: &str) -> Result> { if hex.len() % 2 != 0 { anyhow::bail!("Hex string has odd length"); } (0..hex.len()) .step_by(2) .map(|i| { u8::from_str_radix(&hex[i..i + 2], 16) .map_err(|e| anyhow::anyhow!("Invalid hex at position {i}: {e}")) }) .collect() } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; // ── SecretStore basics ───────────────────────────────────── #[test] fn encrypt_decrypt_roundtrip() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let secret = "sk-my-secret-api-key-12345"; let encrypted = store.encrypt(secret).unwrap(); assert!(encrypted.starts_with("enc2:"), "Should have enc2: prefix"); assert_ne!(encrypted, secret, "Should not be plaintext"); let decrypted = store.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, secret, "Roundtrip must preserve original"); } #[test] fn encrypt_empty_returns_empty() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let result = store.encrypt("").unwrap(); assert_eq!(result, ""); } #[test] fn decrypt_plaintext_passthrough() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); // Values without "enc:"/"enc2:" prefix are returned as-is (backward compat) let result = store.decrypt("sk-plaintext-key").unwrap(); assert_eq!(result, "sk-plaintext-key"); } #[test] fn disabled_store_returns_plaintext() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), false); let result = store.encrypt("sk-secret").unwrap(); assert_eq!(result, "sk-secret", "Disabled store should not encrypt"); } #[test] fn is_encrypted_detects_prefix() { assert!(SecretStore::is_encrypted("enc2:aabbcc")); assert!(SecretStore::is_encrypted("enc:aabbcc")); // legacy assert!(!SecretStore::is_encrypted("sk-plaintext")); assert!(!SecretStore::is_encrypted("")); } #[test] fn key_file_created_on_first_encrypt() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); assert!(!store.key_path.exists()); store.encrypt("test").unwrap(); assert!(store.key_path.exists(), "Key file should be created"); let key_hex = fs::read_to_string(&store.key_path).unwrap(); assert_eq!( key_hex.len(), KEY_LEN * 2, "Key should be {KEY_LEN} bytes hex-encoded" ); } #[test] fn encrypting_same_value_produces_different_ciphertext() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let e1 = store.encrypt("secret").unwrap(); let e2 = store.encrypt("secret").unwrap(); assert_ne!( e1, e2, "AEAD with random nonce should produce different ciphertext each time" ); // Both should still decrypt to the same value assert_eq!(store.decrypt(&e1).unwrap(), "secret"); assert_eq!(store.decrypt(&e2).unwrap(), "secret"); } #[test] fn different_stores_same_dir_interop() { let tmp = TempDir::new().unwrap(); let store1 = SecretStore::new(tmp.path(), true); let store2 = SecretStore::new(tmp.path(), true); let encrypted = store1.encrypt("cross-store-secret").unwrap(); let decrypted = store2.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, "cross-store-secret"); } #[test] fn unicode_secret_roundtrip() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let secret = "sk-日本語テスト-émojis-🦀"; let encrypted = store.encrypt(secret).unwrap(); let decrypted = store.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, secret); } #[test] fn long_secret_roundtrip() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let secret = "a".repeat(10_000); let encrypted = store.encrypt(&secret).unwrap(); let decrypted = store.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, secret); } #[test] fn corrupt_hex_returns_error() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let result = store.decrypt("enc2:not-valid-hex!!"); assert!(result.is_err()); } #[test] fn tampered_ciphertext_detected() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let encrypted = store.encrypt("sensitive-data").unwrap(); // Flip a bit in the ciphertext (after the "enc2:" prefix) let hex_str = &encrypted[5..]; let mut blob = hex_decode(hex_str).unwrap(); // Modify a byte in the ciphertext portion (after the 12-byte nonce) if blob.len() > NONCE_LEN { blob[NONCE_LEN] ^= 0xff; } let tampered = format!("enc2:{}", hex_encode(&blob)); let result = store.decrypt(&tampered); assert!(result.is_err(), "Tampered ciphertext must be rejected"); } #[test] fn wrong_key_detected() { let tmp1 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap(); let store1 = SecretStore::new(tmp1.path(), true); let store2 = SecretStore::new(tmp2.path(), true); let encrypted = store1.encrypt("secret-for-store1").unwrap(); let result = store2.decrypt(&encrypted); assert!(result.is_err(), "Decrypting with a different key must fail"); } #[test] fn truncated_ciphertext_returns_error() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); // Only a few bytes — shorter than nonce let result = store.decrypt("enc2:aabbccdd"); assert!(result.is_err(), "Too-short ciphertext must be rejected"); } // ── Legacy XOR backward compatibility ─────────────────────── #[test] fn legacy_xor_decrypt_still_works() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); // Trigger key creation via an encrypt call let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); // Manually produce a legacy XOR-encrypted value let plaintext = "sk-legacy-api-key"; let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); // Store should still be able to decrypt legacy values let decrypted = store.decrypt(&legacy_value).unwrap(); assert_eq!(decrypted, plaintext, "Legacy XOR values must still decrypt"); } // ── Migration tests ───────────────────────────────────────── #[test] fn needs_migration_detects_legacy_prefix() { assert!(SecretStore::needs_migration("enc:aabbcc")); assert!(!SecretStore::needs_migration("enc2:aabbcc")); assert!(!SecretStore::needs_migration("sk-plaintext")); assert!(!SecretStore::needs_migration("")); } #[test] fn is_secure_encrypted_detects_enc2_only() { assert!(SecretStore::is_secure_encrypted("enc2:aabbcc")); assert!(!SecretStore::is_secure_encrypted("enc:aabbcc")); assert!(!SecretStore::is_secure_encrypted("sk-plaintext")); assert!(!SecretStore::is_secure_encrypted("")); } #[test] fn decrypt_and_migrate_returns_none_for_enc2() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let encrypted = store.encrypt("my-secret").unwrap(); assert!(encrypted.starts_with("enc2:")); let (plaintext, migrated) = store.decrypt_and_migrate(&encrypted).unwrap(); assert_eq!(plaintext, "my-secret"); assert!( migrated.is_none(), "enc2: values should not trigger migration" ); } #[test] fn decrypt_and_migrate_returns_none_for_plaintext() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let (plaintext, migrated) = store.decrypt_and_migrate("sk-plaintext-key").unwrap(); assert_eq!(plaintext, "sk-plaintext-key"); assert!( migrated.is_none(), "Plaintext values should not trigger migration" ); } #[test] fn decrypt_and_migrate_upgrades_legacy_xor() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); // Create key first let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); // Manually create a legacy XOR-encrypted value let plaintext = "sk-legacy-secret-to-migrate"; let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); // Verify it needs migration assert!(SecretStore::needs_migration(&legacy_value)); // Decrypt and migrate let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap(); assert_eq!(decrypted, plaintext, "Plaintext must match original"); assert!(migrated.is_some(), "Legacy value should trigger migration"); let new_value = migrated.unwrap(); assert!( new_value.starts_with("enc2:"), "Migrated value must use enc2: prefix" ); assert!( !SecretStore::needs_migration(&new_value), "Migrated value should not need migration" ); // Verify the migrated value decrypts correctly let (decrypted2, migrated2) = store.decrypt_and_migrate(&new_value).unwrap(); assert_eq!( decrypted2, plaintext, "Migrated value must decrypt to same plaintext" ); assert!( migrated2.is_none(), "Migrated value should not trigger another migration" ); } #[test] fn decrypt_and_migrate_handles_unicode() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); let plaintext = "sk-日本語-émojis-🦀-тест"; let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap(); assert_eq!(decrypted, plaintext); assert!(migrated.is_some()); // Verify migrated value works let new_value = migrated.unwrap(); let (decrypted2, _) = store.decrypt_and_migrate(&new_value).unwrap(); assert_eq!(decrypted2, plaintext); } #[test] fn decrypt_and_migrate_handles_empty_secret() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); // Empty plaintext XOR-encrypted let plaintext = ""; let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap(); assert_eq!(decrypted, plaintext); // Empty string encryption returns empty string (not enc2:) assert!(migrated.is_some()); assert_eq!(migrated.unwrap(), ""); } #[test] fn decrypt_and_migrate_handles_long_secret() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); let plaintext = "a".repeat(10_000); let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap(); assert_eq!(decrypted, plaintext); assert!(migrated.is_some()); let new_value = migrated.unwrap(); let (decrypted2, _) = store.decrypt_and_migrate(&new_value).unwrap(); assert_eq!(decrypted2, plaintext); } #[test] fn decrypt_and_migrate_fails_on_corrupt_legacy_hex() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let _ = store.encrypt("setup").unwrap(); let result = store.decrypt_and_migrate("enc:not-valid-hex!!"); assert!(result.is_err(), "Corrupt hex should fail"); } #[test] fn decrypt_and_migrate_wrong_key_produces_garbage_or_fails() { let tmp1 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap(); let store1 = SecretStore::new(tmp1.path(), true); let store2 = SecretStore::new(tmp2.path(), true); // Create keys for both stores let _ = store1.encrypt("setup").unwrap(); let _ = store2.encrypt("setup").unwrap(); let key1 = store1.load_or_create_key().unwrap(); // Encrypt with store1's key let plaintext = "secret-for-store1"; let ciphertext = xor_cipher(plaintext.as_bytes(), &key1); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); // Decrypt with store2 — XOR will produce garbage bytes // This may fail with UTF-8 error or succeed with garbage plaintext match store2.decrypt_and_migrate(&legacy_value) { Ok((decrypted, _)) => { // If it succeeds, the plaintext should be garbage (not the original) assert_ne!( decrypted, plaintext, "Wrong key should produce garbage plaintext" ); } Err(e) => { // Expected: UTF-8 decoding failure from garbage bytes assert!( e.to_string().contains("UTF-8"), "Error should be UTF-8 related: {e}" ); } } } #[test] fn migration_produces_different_ciphertext_each_time() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); let plaintext = "sk-same-secret"; let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); let (_, migrated1) = store.decrypt_and_migrate(&legacy_value).unwrap(); let (_, migrated2) = store.decrypt_and_migrate(&legacy_value).unwrap(); assert!(migrated1.is_some()); assert!(migrated2.is_some()); assert_ne!( migrated1.unwrap(), migrated2.unwrap(), "Each migration should produce different ciphertext (random nonce)" ); } #[test] fn migrated_value_is_tamper_resistant() { let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); let _ = store.encrypt("setup").unwrap(); let key = store.load_or_create_key().unwrap(); let plaintext = "sk-sensitive-data"; let ciphertext = xor_cipher(plaintext.as_bytes(), &key); let legacy_value = format!("enc:{}", hex_encode(&ciphertext)); let (_, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap(); let new_value = migrated.unwrap(); // Tamper with the migrated value let hex_str = &new_value[5..]; let mut blob = hex_decode(hex_str).unwrap(); if blob.len() > NONCE_LEN { blob[NONCE_LEN] ^= 0xff; } let tampered = format!("enc2:{}", hex_encode(&blob)); let result = store.decrypt_and_migrate(&tampered); assert!(result.is_err(), "Tampered migrated value must be rejected"); } // ── Low-level helpers ─────────────────────────────────────── #[test] fn xor_cipher_roundtrip() { let key = b"testkey123"; let data = b"hello world"; let encrypted = xor_cipher(data, key); let decrypted = xor_cipher(&encrypted, key); assert_eq!(decrypted, data); } #[test] fn xor_cipher_empty_key() { let data = b"passthrough"; let result = xor_cipher(data, &[]); assert_eq!(result, data); } #[test] fn hex_roundtrip() { let data = vec![0x00, 0x01, 0xfe, 0xff, 0xab, 0xcd]; let encoded = hex_encode(&data); assert_eq!(encoded, "0001feffabcd"); let decoded = hex_decode(&encoded).unwrap(); assert_eq!(decoded, data); } #[test] fn hex_decode_odd_length_fails() { assert!(hex_decode("abc").is_err()); } #[test] fn hex_decode_invalid_chars_fails() { assert!(hex_decode("zzzz").is_err()); } #[test] fn generate_random_key_correct_length() { let key = generate_random_key(); assert_eq!(key.len(), KEY_LEN); } #[test] fn generate_random_key_not_all_zeros() { let key = generate_random_key(); assert!(key.iter().any(|&b| b != 0), "Key should not be all zeros"); } #[test] fn two_random_keys_differ() { let k1 = generate_random_key(); let k2 = generate_random_key(); assert_ne!(k1, k2, "Two random keys should differ"); } #[cfg(unix)] #[test] fn key_file_has_restricted_permissions() { use std::os::unix::fs::PermissionsExt; let tmp = TempDir::new().unwrap(); let store = SecretStore::new(tmp.path(), true); store.encrypt("trigger key creation").unwrap(); let perms = fs::metadata(&store.key_path).unwrap().permissions(); assert_eq!( perms.mode() & 0o777, 0o600, "Key file must be owner-only (0600)" ); } }