1use 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#[cfg(test)]
31const KEY_LEN: usize = 32;
32
33const NONCE_LEN: usize = 12;
35
36#[derive(Debug, Clone)]
38pub struct SecretStore {
39 key_path: PathBuf,
41 enabled: bool,
43}
44
45impl SecretStore {
46 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 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 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 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 pub fn decrypt_and_migrate(&self, value: &str) -> Result<(String, Option<String>)> {
110 if let Some(hex_str) = value.strip_prefix("enc2:") {
111 let plaintext = self.decrypt_chacha20(hex_str)?;
113 Ok((plaintext, None))
114 } else if let Some(hex_str) = value.strip_prefix("enc:") {
115 ::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 Ok((value.to_string(), None))
130 }
131 }
132
133 pub fn needs_migration(value: &str) -> bool {
135 value.starts_with("enc:")
136 }
137
138 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 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 pub fn is_encrypted(value: &str) -> bool {
180 value.starts_with("enc2:") || value.starts_with("enc:")
181 }
182
183 pub fn is_secure_encrypted(value: &str) -> bool {
185 value.starts_with("enc2:")
186 }
187
188 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 #[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 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 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
327fn 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
338fn generate_random_key() -> Vec<u8> {
343 ChaCha20Poly1305::generate_key(&mut OsRng).to_vec()
344}
345
346fn 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#[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#[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 #[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 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")); 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 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 let hex_str = &encrypted[5..];
519 let mut blob = hex_decode(hex_str).unwrap();
520 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 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 let result = store.decrypt("enc2:aabbccdd");
569 assert!(result.is_err(), "Too-short ciphertext must be rejected");
570 }
571
572 #[test]
575 fn legacy_xor_decrypt_still_works() {
576 let tmp = TempDir::new().unwrap();
577 let store = SecretStore::new(tmp.path(), true);
578
579 let _ = store.encrypt("setup").unwrap();
581 let key = store.load_or_create_key().unwrap();
582
583 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 let decrypted = store.decrypt(&legacy_value).unwrap();
590 assert_eq!(decrypted, plaintext, "Legacy XOR values must still decrypt");
591 }
592
593 #[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 let _ = store.encrypt("setup").unwrap();
647 let key = store.load_or_create_key().unwrap();
648
649 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 assert!(SecretStore::needs_migration(&legacy_value));
656
657 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 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 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 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 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 let _ = store1.encrypt("setup").unwrap();
766 let _ = store2.encrypt("setup").unwrap();
767 let key1 = store1.load_or_create_key().unwrap();
768
769 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 match store2.decrypt_and_migrate(&legacy_value) {
777 Ok((decrypted, _)) => {
778 assert_ne!(
780 decrypted, plaintext,
781 "Wrong key should produce garbage plaintext"
782 );
783 }
784 Err(e) => {
785 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 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 #[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 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 if key[6] & 0xf0 == 0x40 {
935 version_match += 1;
936 }
937 if key[8] & 0xc0 == 0x80 {
939 variant_match += 1;
940 }
941 }
942 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 #[test]
978 fn takeown_runs_before_icacls_on_windows() {
979 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}