1#![allow(unused_assignments)]
2use aes_gcm::{
12 aead::{Aead, KeyInit, OsRng},
13 Aes256Gcm, Nonce,
14};
15use lazy_static::lazy_static;
16use pbkdf2::pbkdf2_hmac;
17use serde::{Deserialize, Serialize};
18use sha2::Sha512;
19use std::collections::HashMap;
20use std::fs;
21use std::io::IsTerminal;
22use std::path::Path;
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::Mutex;
25use std::time::{Duration, SystemTime};
26use zeroize::{ZeroizeOnDrop, Zeroizing};
27
28const KEY_SIZE: usize = 32;
30
31const DEFAULT_CACHE_TIMEOUT: Duration = Duration::from_secs(300);
33
34struct CachedPassphrase {
37 passphrase: Zeroizing<String>,
38 expires_at: SystemTime,
39}
40
41lazy_static! {
42 static ref PASSPHRASE_CACHE: Mutex<HashMap<String, CachedPassphrase>> = Mutex::new(HashMap::new());
44
45 static ref FAILED_ATTEMPTS: Mutex<HashMap<String, FailedAttemptTracker>> = Mutex::new(HashMap::new());
47
48 static ref DERIVED_KEYS: Mutex<HashMap<String, std::sync::Arc<EncryptionKey>>> = Mutex::new(HashMap::new());
51}
52
53struct FailedAttemptTracker {
55 count: u32,
56 last_attempt: SystemTime,
57 lockout_until: Option<SystemTime>,
58}
59
60const NONCE_SIZE: usize = 12;
62
63const PBKDF2_ITERATIONS: u32 = 600_000;
69
70const SALT_SIZE: usize = 16;
73
74const ENCRYPTION_VERSION: u8 = 1;
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EncryptionConfig {
80 pub enabled: bool,
82 pub key_file: String,
84 pub fips_mode: bool,
86 #[serde(default = "default_cache_timeout")]
88 pub cache_timeout_secs: u64,
89}
90
91fn default_cache_timeout() -> u64 {
92 300 }
94
95impl Default for EncryptionConfig {
96 fn default() -> Self {
97 EncryptionConfig {
98 enabled: false,
99 key_file: "~/.lit/encryption.key".to_string(),
100 fips_mode: true,
101 cache_timeout_secs: default_cache_timeout(),
102 }
103 }
104}
105
106impl EncryptionConfig {
107 pub fn load(repo_path: &Path) -> Result<Self, String> {
109 let config_path = repo_path.join(".lit").join("encryption.toml");
110
111 if !config_path.exists() {
112 return Ok(Self::default());
113 }
114
115 let content = fs::read_to_string(&config_path)
116 .map_err(|e| format!("Failed to read encryption config: {}", e))?;
117
118 toml::from_str(&content).map_err(|e| format!("Failed to parse encryption config: {}", e))
119 }
120
121 pub fn save(&self, repo_path: &Path) -> Result<(), String> {
123 let config_path = repo_path.join(".lit").join("encryption.toml");
124
125 let content = toml::to_string_pretty(self)
126 .map_err(|e| format!("Failed to serialize encryption config: {}", e))?;
127
128 fs::write(&config_path, content)
129 .map_err(|e| format!("Failed to write encryption config: {}", e))
130 }
131}
132
133fn derived_key_id(key_file: &str, passphrase: &str) -> String {
139 use sha3::{Digest, Sha3_256};
140 let mut hasher = Sha3_256::new();
141 hasher.update(key_file.as_bytes());
142 hasher.update([0u8]); hasher.update(passphrase.as_bytes());
144 hex::encode(hasher.finalize())
145}
146
147fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
149 DERIVED_KEYS.lock().ok()?.get(id).cloned()
150}
151
152fn remember_derived_key(id: String, key: std::sync::Arc<EncryptionKey>) {
154 if let Ok(mut keys) = DERIVED_KEYS.lock() {
155 keys.insert(id, key);
156 }
157}
158
159fn check_rate_limit(repo_path: &str) -> Result<(), String> {
162 let mut attempts = FAILED_ATTEMPTS
163 .lock()
164 .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
165 let tracker = attempts
166 .entry(repo_path.to_string())
167 .or_insert_with(|| FailedAttemptTracker {
168 count: 0,
169 last_attempt: SystemTime::now(),
170 lockout_until: None,
171 });
172
173 if let Some(lockout) = tracker.lockout_until {
175 if SystemTime::now() < lockout {
176 let remaining = lockout
177 .duration_since(SystemTime::now())
178 .unwrap_or(Duration::from_secs(0));
179 return Err(format!(
180 "Too many failed attempts. Please wait {} seconds before trying again.",
181 remaining.as_secs()
182 ));
183 }
184 tracker.lockout_until = None;
186 tracker.count = 0;
187 }
188
189 if tracker.count > 0 {
191 let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
192 if let Ok(elapsed) = tracker.last_attempt.elapsed() {
193 if elapsed < delay {
194 let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
195 return Err(format!(
196 "Please wait {} seconds between passphrase attempts.",
197 remaining
198 ));
199 }
200 }
201 }
202
203 Ok(())
204}
205
206fn record_failed_attempt(repo_path: &str) {
208 let Ok(mut attempts) = FAILED_ATTEMPTS.lock() else {
209 return;
210 };
211 let tracker = attempts
212 .entry(repo_path.to_string())
213 .or_insert_with(|| FailedAttemptTracker {
214 count: 0,
215 last_attempt: SystemTime::now(),
216 lockout_until: None,
217 });
218
219 tracker.count += 1;
220 tracker.last_attempt = SystemTime::now();
221
222 if tracker.count >= 5 {
224 tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
225 eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
226 }
227}
228
229fn clear_failed_attempts(repo_path: &str) {
231 if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
232 attempts.remove(repo_path);
233 }
234}
235
236#[derive(ZeroizeOnDrop)]
238#[allow(unused_assignments)]
239pub struct EncryptionKey {
240 key_bytes: [u8; KEY_SIZE],
241 #[zeroize(skip)]
243 salt: [u8; SALT_SIZE],
244}
245
246impl EncryptionKey {
247 pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
249 #[cfg(not(test))]
251 validate_passphrase_strength(passphrase)?;
252 #[cfg(test)]
253 if !passphrase.starts_with("test-") {
254 validate_passphrase_strength(passphrase)?;
255 }
256
257 if salt.len() != SALT_SIZE {
258 return Err(format!(
259 "Invalid salt size: expected {}, got {}",
260 SALT_SIZE,
261 salt.len()
262 ));
263 }
264
265 let mut key_bytes = [0u8; KEY_SIZE];
266 pbkdf2_hmac::<Sha512>(
267 passphrase.as_bytes(),
268 salt,
269 PBKDF2_ITERATIONS,
270 &mut key_bytes,
271 );
272
273 let mut salt_array = [0u8; SALT_SIZE];
274 salt_array.copy_from_slice(salt);
275
276 Ok(EncryptionKey {
277 key_bytes,
278 salt: salt_array,
279 })
280 }
281
282 pub fn generate_salt() -> [u8; SALT_SIZE] {
284 use aes_gcm::aead::rand_core::RngCore;
285 let mut salt = [0u8; SALT_SIZE];
286 OsRng.fill_bytes(&mut salt);
287 salt
288 }
289
290 pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
294 let key_file_str = key_file.to_string_lossy().to_string();
296 #[cfg(not(test))]
297 check_rate_limit(&key_file_str)?;
298 #[cfg(test)]
299 if !passphrase.starts_with("test-") {
300 check_rate_limit(&key_file_str)?;
301 }
302
303 if !key_file.exists() {
304 return Err(
305 "Encryption key file not found. Initialize repository with encryption first."
306 .to_string(),
307 );
308 }
309
310 let encrypted_data =
311 fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
312
313 if encrypted_data.len() < SALT_SIZE + 1 {
314 return Err("Invalid key file format (too short)".to_string());
315 }
316
317 let salt = &encrypted_data[0..SALT_SIZE];
319 let version = encrypted_data[SALT_SIZE];
320
321 if version != ENCRYPTION_VERSION {
322 return Err(format!("Unsupported key file version: {}", version));
323 }
324
325 if encrypted_data.len() == SALT_SIZE + 1 {
327 let key = Self::from_passphrase(passphrase, salt)?;
329 clear_failed_attempts(&key_file_str);
331 return Ok(key);
332 }
333
334 if encrypted_data.len() < SALT_SIZE + 1 + 32 {
335 return Err("Invalid key file format (unexpected size)".to_string());
336 }
337
338 let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
339
340 let key = Self::from_passphrase(passphrase, salt)?;
342
343 use sha2::{Digest, Sha256};
345 let mut hasher = Sha256::new();
346 hasher.update(b"lit-passphrase-verification-v1");
347 hasher.update(&key.key_bytes);
348 let verification_hash = hasher.finalize();
349
350 use subtle::ConstantTimeEq;
352 if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
353 #[cfg(not(test))]
355 record_failed_attempt(&key_file_str);
356 #[cfg(test)]
357 if !passphrase.starts_with("test-") {
358 record_failed_attempt(&key_file_str);
359 }
360 std::thread::sleep(std::time::Duration::from_millis(100));
362 return Err("Invalid passphrase".to_string());
363 }
364
365 clear_failed_attempts(&key_file_str);
367 Ok(key)
368 }
369
370 pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
374 let expanded = shellexpand::tilde(key_file_str);
375 let key_file = Path::new(expanded.as_ref());
376
377 use sha2::{Digest, Sha256};
379 let mut hasher = Sha256::new();
380 hasher.update(b"lit-passphrase-verification-v1");
381 hasher.update(self.key_bytes);
382 let verification_hash = hasher.finalize();
383
384 if let Some(parent) = key_file.parent() {
386 fs::create_dir_all(parent)
387 .map_err(|e| format!("Failed to create key directory: {}", e))?;
388 }
389
390 let mut data = Vec::new();
392 data.extend_from_slice(&self.salt);
393 data.push(ENCRYPTION_VERSION);
394 data.extend_from_slice(&verification_hash);
395
396 let temp_file = key_file.with_extension("tmp");
398 fs::write(&temp_file, &data)
399 .map_err(|e| format!("Failed to write temp key file: {}", e))?;
400 fs::rename(&temp_file, key_file)
401 .map_err(|e| format!("Failed to rename key file: {}", e))?;
402
403 Ok(())
404 }
405
406 fn as_bytes(&self) -> &[u8; KEY_SIZE] {
408 &self.key_bytes
409 }
410}
411
412const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
415
416pub struct EncryptionEngine {
419 cipher: Aes256Gcm,
420 nonce_counter: AtomicU64,
422}
423
424impl EncryptionEngine {
425 pub fn new(key: &EncryptionKey) -> Result<Self, String> {
427 let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
428 .map_err(|e| format!("Failed to create cipher: {}", e))?;
429
430 Ok(EncryptionEngine {
431 cipher,
432 nonce_counter: AtomicU64::new(0),
433 })
434 }
435
436 #[allow(deprecated)]
441 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
442 let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
449 if count >= MAX_ENCRYPTIONS_PER_KEY {
450 return Err(format!(
451 "Encryption limit exceeded ({} operations). Key rotation required for security.",
452 MAX_ENCRYPTIONS_PER_KEY
453 ));
454 }
455
456 use aes_gcm::aead::rand_core::RngCore;
473 let mut nonce_bytes = [0u8; NONCE_SIZE];
474 OsRng.fill_bytes(&mut nonce_bytes);
475 let nonce = Nonce::from_slice(&nonce_bytes);
476
477 let ciphertext = self
479 .cipher
480 .encrypt(nonce, plaintext)
481 .map_err(|e| format!("Encryption failed: {}", e))?;
482
483 let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
485 output.push(ENCRYPTION_VERSION);
486 output.extend_from_slice(&nonce_bytes);
487 output.extend_from_slice(&ciphertext);
488
489 Ok(output)
490 }
491
492 #[allow(deprecated)]
494 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
495 if encrypted.len() < 1 + NONCE_SIZE {
496 return Err("Invalid encrypted data: too short".to_string());
497 }
498
499 let version = encrypted[0];
501 if version != ENCRYPTION_VERSION {
502 return Err(format!("Unsupported encryption version: {}", version));
503 }
504
505 let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
507 let nonce = Nonce::from_slice(nonce_bytes);
508
509 let ciphertext = &encrypted[1 + NONCE_SIZE..];
511
512 let plaintext = self
514 .cipher
515 .decrypt(nonce, ciphertext)
516 .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
517
518 Ok(plaintext)
519 }
520}
521
522impl CachedPassphrase {
524 fn is_valid(&self) -> bool {
526 SystemTime::now() < self.expires_at
527 }
528}
529
530pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
533 let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
534 let expires_at = SystemTime::now() + timeout;
535
536 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
537 cache.insert(
538 repo_path.to_string(),
539 CachedPassphrase {
540 passphrase: Zeroizing::new(passphrase),
541 expires_at,
542 },
543 );
544 }
545}
546
547pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
550 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
551 if let Some(entry) = cache.get(repo_path) {
552 if entry.is_valid() {
553 return Some(entry.passphrase.clone());
554 } else {
555 cache.remove(repo_path);
557 }
558 }
559 }
560 None
561}
562
563pub fn clear_passphrase_cache() {
565 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
566 cache.clear();
567 }
568}
569
570pub fn clear_cached_passphrase(repo_path: &str) {
572 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
573 cache.remove(repo_path);
574 }
575}
576
577fn get_passphrase_non_interactive(
583 repo_path: &str,
584 config: &EncryptionConfig,
585) -> Option<Zeroizing<String>> {
586 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
588 if !pass.is_empty() {
589 return Some(Zeroizing::new(pass));
590 }
591 }
592
593 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
595 if let Ok(pass) = std::fs::read_to_string(&path) {
596 let pass = pass
597 .trim_end_matches('\n')
598 .trim_end_matches('\r')
599 .to_string();
600 if !pass.is_empty() {
601 return Some(Zeroizing::new(pass));
602 }
603 }
604 }
605
606 if config.cache_timeout_secs > 0 {
608 if let Some(cached) = get_cached_passphrase(repo_path) {
609 return Some(cached);
610 }
611 }
612
613 None
614}
615
616pub fn prompt_for_passphrase(
622 repo_path: &str,
623 config: &EncryptionConfig,
624 prompt_text: &str,
625) -> Result<Zeroizing<String>, String> {
626 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
628 return Ok(pass);
629 }
630
631 if !std::io::stdin().is_terminal() {
634 return Err(
635 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
636 LIT_PASSPHRASE_FILE"
637 .to_string(),
638 );
639 }
640
641 rpassword::prompt_password(prompt_text)
643 .map(Zeroizing::new)
644 .map_err(|e| format!("Failed to read passphrase: {}", e))
645}
646
647const MIN_PASSPHRASE_LENGTH: usize = 16;
649
650fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
656 #[cfg(test)]
658 if passphrase.starts_with("test-") {
659 return Ok(());
660 }
661
662 if passphrase.len() < MIN_PASSPHRASE_LENGTH {
663 return Err(format!(
664 "Passphrase must be at least {} characters (recommended: 20+)",
665 MIN_PASSPHRASE_LENGTH
666 ));
667 }
668
669 let has_upper = passphrase.chars().any(|c| c.is_uppercase());
671 let has_lower = passphrase.chars().any(|c| c.is_lowercase());
672 let has_digit = passphrase.chars().any(|c| c.is_numeric());
673 let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
674
675 let complexity_count = [has_upper, has_lower, has_digit, has_special]
676 .iter()
677 .filter(|&&x| x)
678 .count();
679
680 if complexity_count < 3 {
681 return Err(
682 "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
683 .to_string(),
684 );
685 }
686
687 Ok(())
688}
689
690pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
694 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
696 if !pass.is_empty() {
697 validate_passphrase_strength(&pass)?;
698 return Ok(Zeroizing::new(pass));
699 }
700 }
701
702 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
704 if let Ok(pass) = std::fs::read_to_string(&path) {
705 let pass = pass
706 .trim_end_matches('\n')
707 .trim_end_matches('\r')
708 .to_string();
709 if !pass.is_empty() {
710 validate_passphrase_strength(&pass)?;
711 return Ok(Zeroizing::new(pass));
712 }
713 }
714 }
715
716 if !std::io::stdin().is_terminal() {
718 return Err(
719 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
720 LIT_PASSPHRASE_FILE"
721 .to_string(),
722 );
723 }
724
725 let pass1 = rpassword::prompt_password(prompt_text)
727 .map_err(|e| format!("Failed to read passphrase: {}", e))?;
728
729 let pass2 = rpassword::prompt_password("Confirm passphrase: ")
730 .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
731
732 if pass1 != pass2 {
733 return Err("Passphrases do not match".to_string());
734 }
735
736 validate_passphrase_strength(&pass1)?;
737
738 Ok(Zeroizing::new(pass1))
739}
740
741pub struct EncryptionManager {
743 config: EncryptionConfig,
744 engine: Option<EncryptionEngine>,
745 repo_path: Option<String>,
746}
747
748impl EncryptionManager {
749 pub fn new(config: EncryptionConfig) -> Self {
751 EncryptionManager {
752 config,
753 engine: None,
754 repo_path: None,
755 }
756 }
757
758 pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
771 let mut manager = EncryptionManager::new(config);
772 if !manager.config.enabled {
773 return manager;
774 }
775
776 let repo = repo_path.to_string_lossy().to_string();
777 let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
778 return manager;
779 };
780
781 manager.repo_path = Some(repo.clone());
782 if let Err(e) = manager.initialize(&passphrase) {
783 eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
784 return manager;
785 }
786
787 if manager.config.cache_timeout_secs > 0 {
788 let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
789 cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
790 }
791
792 manager
793 }
794
795 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
797 if !self.config.enabled {
798 return Ok(());
799 }
800
801 let expanded = shellexpand::tilde(&self.config.key_file);
802 let key_file = Path::new(expanded.as_ref());
803
804 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
815 if let Some(key) = cached_derived_key(&cache_id) {
816 self.engine = Some(EncryptionEngine::new(&key)?);
817 return Ok(());
818 }
819
820 let key = if key_file.exists() {
822 EncryptionKey::load(key_file, passphrase)?
823 } else {
824 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
825 key.save(&self.config.key_file, passphrase)?;
826 key
827 };
828
829 let key = std::sync::Arc::new(key);
830 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
831
832 self.engine = Some(EncryptionEngine::new(&key)?);
834
835 Ok(())
836 }
837
838 pub fn initialize_with_cache(
840 &mut self,
841 repo_path: &str,
842 passphrase: Option<&str>,
843 ) -> Result<(), String> {
844 if !self.config.enabled {
845 return Ok(());
846 }
847
848 self.repo_path = Some(repo_path.to_string());
849
850 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
852 Zeroizing::new(pass.to_string())
853 } else if let Some(cached) = get_cached_passphrase(repo_path) {
854 cached
855 } else {
856 return Err("No passphrase provided and no valid cached passphrase found".to_string());
857 };
858
859 self.initialize(&actual_passphrase)?;
861
862 if self.config.cache_timeout_secs > 0 {
864 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
865 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
866 }
867
868 Ok(())
869 }
870
871 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
873 if !self.config.enabled {
874 return Ok(plaintext.to_vec());
875 }
876
877 match &self.engine {
878 Some(engine) => engine.encrypt(plaintext),
879 None => Err(
880 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
881 ),
882 }
883 }
884
885 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
887 if !self.config.enabled {
888 return Ok(encrypted.to_vec());
889 }
890
891 match &self.engine {
892 Some(engine) => {
893 if encrypted
900 .first()
901 .is_some_and(|version| *version != ENCRYPTION_VERSION)
902 {
903 return Err(
904 "This data has no Lit encryption header. Encryption cannot be \
905 enabled for a repository that already contains unencrypted \
906 commits — start a new encrypted repository and import into it."
907 .to_string(),
908 );
909 }
910 engine.decrypt(encrypted)
911 }
912 None => Err(
913 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
914 ),
915 }
916 }
917
918 pub fn is_enabled(&self) -> bool {
920 self.config.enabled
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
936
937 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
938 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
939 }
940
941 fn test_key_path(label: &str) -> std::path::PathBuf {
950 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
951 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
952 let path = std::env::temp_dir().join(format!(
953 "lit_enc_test_{}_{}_{}.key",
954 std::process::id(),
955 label,
956 n
957 ));
958 let _ = fs::remove_file(&path);
959 path
960 }
961
962 #[test]
963 fn test_key_derivation() {
964 let passphrase = "test-passphrase-12345";
965 let salt = EncryptionKey::generate_salt();
966
967 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
968 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
969
970 assert_eq!(key1.as_bytes(), key2.as_bytes());
972 }
973
974 #[test]
975 fn test_encryption_decryption() {
976 let passphrase = "test-secure-passphrase";
977 let salt = EncryptionKey::generate_salt();
978 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
979
980 let engine = EncryptionEngine::new(&key).unwrap();
981
982 let plaintext = b"Hello, this is secret data!";
983
984 let encrypted = engine.encrypt(plaintext).unwrap();
986
987 assert_ne!(encrypted.as_slice(), plaintext);
989
990 let decrypted = engine.decrypt(&encrypted).unwrap();
992
993 assert_eq!(decrypted.as_slice(), plaintext);
995 }
996
997 #[test]
998 fn test_encryption_nonce_randomness() {
999 let passphrase = "test-passphrase";
1000 let salt = EncryptionKey::generate_salt();
1001 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1002
1003 let engine = EncryptionEngine::new(&key).unwrap();
1004
1005 let plaintext = b"Same data";
1006
1007 let encrypted1 = engine.encrypt(plaintext).unwrap();
1009 let encrypted2 = engine.encrypt(plaintext).unwrap();
1010
1011 assert_ne!(encrypted1, encrypted2);
1013
1014 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1016 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1017 }
1018
1019 #[test]
1020 fn test_tampering_detection() {
1021 let passphrase = "test-passphrase";
1022 let salt = EncryptionKey::generate_salt();
1023 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1024
1025 let engine = EncryptionEngine::new(&key).unwrap();
1026
1027 let plaintext = b"Secret data";
1028 let mut encrypted = engine.encrypt(plaintext).unwrap();
1029
1030 let len = encrypted.len();
1032 encrypted[len - 1] ^= 0x01;
1033
1034 assert!(engine.decrypt(&encrypted).is_err());
1036 }
1037
1038 #[test]
1039 fn test_encryption_manager_disabled() {
1040 let config = EncryptionConfig {
1041 enabled: false,
1042 ..Default::default()
1043 };
1044
1045 let manager = EncryptionManager::new(config);
1046
1047 let data = b"Some data";
1048
1049 assert_eq!(manager.encrypt(data).unwrap(), data);
1051 assert_eq!(manager.decrypt(data).unwrap(), data);
1052 }
1053
1054 #[test]
1055 fn test_passphrase_caching() {
1056 let _guard = cache_test_guard();
1057 let repo_path = "/tmp/test-repo";
1058 let passphrase = "cache-test-passphrase".to_string();
1059
1060 clear_passphrase_cache();
1062
1063 assert!(get_cached_passphrase(repo_path).is_none());
1065
1066 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1068
1069 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1071
1072 clear_cached_passphrase(repo_path);
1074 assert!(get_cached_passphrase(repo_path).is_none());
1075 }
1076
1077 #[test]
1078 fn test_passphrase_cache_expiration() {
1079 let _guard = cache_test_guard();
1080 let repo_path = "/tmp/test-repo-expire";
1081 let passphrase = "expire-test".to_string();
1082
1083 clear_passphrase_cache();
1084
1085 cache_passphrase(
1092 repo_path,
1093 passphrase.clone(),
1094 Some(Duration::from_millis(200)),
1095 );
1096
1097 std::thread::sleep(Duration::from_millis(600));
1098
1099 assert!(get_cached_passphrase(repo_path).is_none());
1101 }
1102
1103 #[test]
1104 fn test_passphrase_cache_multiple_repos() {
1105 let _guard = cache_test_guard();
1106 let repo1 = "/tmp/multi-cache-repo1";
1107 let repo2 = "/tmp/multi-cache-repo2";
1108 let pass1 = "password1".to_string();
1109 let pass2 = "password2".to_string();
1110
1111 clear_passphrase_cache();
1112
1113 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1115 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1116
1117 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1119 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1120 }
1121
1122 #[test]
1123 fn test_encryption_manager_with_cache() {
1124 use std::env;
1125
1126 let _guard = cache_test_guard();
1127
1128 let key_file = test_key_path("manager_cache");
1129
1130 let temp_dir = env::temp_dir();
1131 let repo_path = temp_dir.join("test-cache-manager");
1132 let repo_str = repo_path.to_str().unwrap();
1133
1134 clear_passphrase_cache();
1135
1136 let config = EncryptionConfig {
1137 enabled: true,
1138 key_file: key_file.to_string_lossy().into_owned(),
1139 cache_timeout_secs: 300, ..Default::default()
1141 };
1142
1143 let mut manager = EncryptionManager::new(config);
1144 let passphrase = "test-cache-manager-pass";
1145
1146 manager
1148 .initialize_with_cache(repo_str, Some(passphrase))
1149 .unwrap();
1150
1151 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1153
1154 let mut manager2 = EncryptionManager::new(manager.config.clone());
1156 manager2.initialize_with_cache(repo_str, None).unwrap();
1157
1158 clear_passphrase_cache();
1160 let _ = fs::remove_file(&key_file);
1161 }
1162
1163 #[test]
1169 #[ignore]
1170 fn test_rate_limiting() {
1171 let key_file = test_key_path("rate_limiting");
1172 let key_file_str = key_file.to_string_lossy().into_owned();
1173
1174 let passphrase = "correct-passphrase-1234567890";
1177 let salt = EncryptionKey::generate_salt();
1178 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1179 key.save(&key_file_str, passphrase).unwrap();
1180
1181 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1183
1184 let start = std::time::Instant::now();
1191 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1192 .err()
1193 .expect("an attempt inside the backoff window must be refused");
1194 assert!(
1195 throttled.contains("wait"),
1196 "expected a rate-limit refusal, got: {}",
1197 throttled
1198 );
1199 assert!(
1200 start.elapsed() < Duration::from_secs(1),
1201 "the throttle should refuse immediately rather than block the caller"
1202 );
1203
1204 std::thread::sleep(Duration::from_millis(2_100));
1207 let correct = EncryptionKey::load(&key_file, passphrase);
1208 assert!(
1209 correct.is_ok(),
1210 "the correct passphrase should be accepted once the window passes: {:?}",
1211 correct.as_ref().err()
1212 );
1213
1214 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1217 .err()
1218 .expect("a wrong passphrase must still fail");
1219 assert!(
1220 !after_reset.contains("wait"),
1221 "a successful load should reset the counter, got: {}",
1222 after_reset
1223 );
1224
1225 let _ = fs::remove_file(&key_file);
1226 }
1227
1228 #[test]
1237 fn test_nonces_do_not_repeat_across_engines() {
1238 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1239
1240 let mut nonces = std::collections::HashSet::new();
1241 let mut leading_zero_runs = 0;
1242
1243 for _ in 0..64 {
1244 let engine = EncryptionEngine::new(&key).unwrap();
1246 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1247 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1248
1249 if nonce[..8] == [0u8; 8] {
1250 leading_zero_runs += 1;
1251 }
1252 assert!(
1253 nonces.insert(nonce),
1254 "a nonce repeated across engines, which breaks AES-GCM"
1255 );
1256 }
1257
1258 assert!(
1260 leading_zero_runs <= 1,
1261 "{} of 64 nonces began with eight zero bytes, which means the \
1262 counter is resetting rather than the nonce being random",
1263 leading_zero_runs
1264 );
1265 }
1266}