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 is_encrypted_payload(data: &[u8]) -> bool {
802 data.first() == Some(&ENCRYPTION_VERSION)
803 }
804
805 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
807 if !self.config.enabled {
808 return Ok(());
809 }
810
811 let expanded = shellexpand::tilde(&self.config.key_file);
812 let key_file = Path::new(expanded.as_ref());
813
814 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
825 if let Some(key) = cached_derived_key(&cache_id) {
826 self.engine = Some(EncryptionEngine::new(&key)?);
827 return Ok(());
828 }
829
830 let key = if key_file.exists() {
832 EncryptionKey::load(key_file, passphrase)?
833 } else {
834 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
835 key.save(&self.config.key_file, passphrase)?;
836 key
837 };
838
839 let key = std::sync::Arc::new(key);
840 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
841
842 self.engine = Some(EncryptionEngine::new(&key)?);
844
845 Ok(())
846 }
847
848 pub fn initialize_with_cache(
850 &mut self,
851 repo_path: &str,
852 passphrase: Option<&str>,
853 ) -> Result<(), String> {
854 if !self.config.enabled {
855 return Ok(());
856 }
857
858 self.repo_path = Some(repo_path.to_string());
859
860 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
862 Zeroizing::new(pass.to_string())
863 } else if let Some(cached) = get_cached_passphrase(repo_path) {
864 cached
865 } else {
866 return Err("No passphrase provided and no valid cached passphrase found".to_string());
867 };
868
869 self.initialize(&actual_passphrase)?;
871
872 if self.config.cache_timeout_secs > 0 {
874 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
875 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
876 }
877
878 Ok(())
879 }
880
881 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
883 if !self.config.enabled {
884 return Ok(plaintext.to_vec());
885 }
886
887 match &self.engine {
888 Some(engine) => engine.encrypt(plaintext),
889 None => Err(
890 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
891 ),
892 }
893 }
894
895 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
897 if !self.config.enabled {
898 return Ok(encrypted.to_vec());
899 }
900
901 match &self.engine {
902 Some(engine) => {
903 if encrypted
910 .first()
911 .is_some_and(|version| *version != ENCRYPTION_VERSION)
912 {
913 return Err(
914 "This data has no Lit encryption header. Encryption cannot be \
915 enabled for a repository that already contains unencrypted \
916 commits — start a new encrypted repository and import into it."
917 .to_string(),
918 );
919 }
920 engine.decrypt(encrypted)
921 }
922 None => Err(
923 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
924 ),
925 }
926 }
927
928 pub fn is_enabled(&self) -> bool {
930 self.config.enabled
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937
938 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
946
947 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
948 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
949 }
950
951 fn test_key_path(label: &str) -> std::path::PathBuf {
960 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
961 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
962 let path = std::env::temp_dir().join(format!(
963 "lit_enc_test_{}_{}_{}.key",
964 std::process::id(),
965 label,
966 n
967 ));
968 let _ = fs::remove_file(&path);
969 path
970 }
971
972 #[test]
973 fn test_key_derivation() {
974 let passphrase = "test-passphrase-12345";
975 let salt = EncryptionKey::generate_salt();
976
977 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
978 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
979
980 assert_eq!(key1.as_bytes(), key2.as_bytes());
982 }
983
984 #[test]
985 fn test_encryption_decryption() {
986 let passphrase = "test-secure-passphrase";
987 let salt = EncryptionKey::generate_salt();
988 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
989
990 let engine = EncryptionEngine::new(&key).unwrap();
991
992 let plaintext = b"Hello, this is secret data!";
993
994 let encrypted = engine.encrypt(plaintext).unwrap();
996
997 assert_ne!(encrypted.as_slice(), plaintext);
999
1000 let decrypted = engine.decrypt(&encrypted).unwrap();
1002
1003 assert_eq!(decrypted.as_slice(), plaintext);
1005 }
1006
1007 #[test]
1008 fn test_encryption_nonce_randomness() {
1009 let passphrase = "test-passphrase";
1010 let salt = EncryptionKey::generate_salt();
1011 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1012
1013 let engine = EncryptionEngine::new(&key).unwrap();
1014
1015 let plaintext = b"Same data";
1016
1017 let encrypted1 = engine.encrypt(plaintext).unwrap();
1019 let encrypted2 = engine.encrypt(plaintext).unwrap();
1020
1021 assert_ne!(encrypted1, encrypted2);
1023
1024 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1026 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1027 }
1028
1029 #[test]
1030 fn test_tampering_detection() {
1031 let passphrase = "test-passphrase";
1032 let salt = EncryptionKey::generate_salt();
1033 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1034
1035 let engine = EncryptionEngine::new(&key).unwrap();
1036
1037 let plaintext = b"Secret data";
1038 let mut encrypted = engine.encrypt(plaintext).unwrap();
1039
1040 let len = encrypted.len();
1042 encrypted[len - 1] ^= 0x01;
1043
1044 assert!(engine.decrypt(&encrypted).is_err());
1046 }
1047
1048 #[test]
1049 fn test_encryption_manager_disabled() {
1050 let config = EncryptionConfig {
1051 enabled: false,
1052 ..Default::default()
1053 };
1054
1055 let manager = EncryptionManager::new(config);
1056
1057 let data = b"Some data";
1058
1059 assert_eq!(manager.encrypt(data).unwrap(), data);
1061 assert_eq!(manager.decrypt(data).unwrap(), data);
1062 }
1063
1064 #[test]
1065 fn test_passphrase_caching() {
1066 let _guard = cache_test_guard();
1067 let repo_path = "/tmp/test-repo";
1068 let passphrase = "cache-test-passphrase".to_string();
1069
1070 clear_passphrase_cache();
1072
1073 assert!(get_cached_passphrase(repo_path).is_none());
1075
1076 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1078
1079 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1081
1082 clear_cached_passphrase(repo_path);
1084 assert!(get_cached_passphrase(repo_path).is_none());
1085 }
1086
1087 #[test]
1088 fn test_passphrase_cache_expiration() {
1089 let _guard = cache_test_guard();
1090 let repo_path = "/tmp/test-repo-expire";
1091 let passphrase = "expire-test".to_string();
1092
1093 clear_passphrase_cache();
1094
1095 cache_passphrase(
1102 repo_path,
1103 passphrase.clone(),
1104 Some(Duration::from_millis(200)),
1105 );
1106
1107 std::thread::sleep(Duration::from_millis(600));
1108
1109 assert!(get_cached_passphrase(repo_path).is_none());
1111 }
1112
1113 #[test]
1114 fn test_passphrase_cache_multiple_repos() {
1115 let _guard = cache_test_guard();
1116 let repo1 = "/tmp/multi-cache-repo1";
1117 let repo2 = "/tmp/multi-cache-repo2";
1118 let pass1 = "password1".to_string();
1119 let pass2 = "password2".to_string();
1120
1121 clear_passphrase_cache();
1122
1123 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1125 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1126
1127 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1129 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1130 }
1131
1132 #[test]
1133 fn test_encryption_manager_with_cache() {
1134 use std::env;
1135
1136 let _guard = cache_test_guard();
1137
1138 let key_file = test_key_path("manager_cache");
1139
1140 let temp_dir = env::temp_dir();
1141 let repo_path = temp_dir.join("test-cache-manager");
1142 let repo_str = repo_path.to_str().unwrap();
1143
1144 clear_passphrase_cache();
1145
1146 let config = EncryptionConfig {
1147 enabled: true,
1148 key_file: key_file.to_string_lossy().into_owned(),
1149 cache_timeout_secs: 300, ..Default::default()
1151 };
1152
1153 let mut manager = EncryptionManager::new(config);
1154 let passphrase = "test-cache-manager-pass";
1155
1156 manager
1158 .initialize_with_cache(repo_str, Some(passphrase))
1159 .unwrap();
1160
1161 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1163
1164 let mut manager2 = EncryptionManager::new(manager.config.clone());
1166 manager2.initialize_with_cache(repo_str, None).unwrap();
1167
1168 clear_passphrase_cache();
1170 let _ = fs::remove_file(&key_file);
1171 }
1172
1173 #[test]
1179 #[ignore]
1180 fn test_rate_limiting() {
1181 let key_file = test_key_path("rate_limiting");
1182 let key_file_str = key_file.to_string_lossy().into_owned();
1183
1184 let passphrase = "correct-passphrase-1234567890";
1187 let salt = EncryptionKey::generate_salt();
1188 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1189 key.save(&key_file_str, passphrase).unwrap();
1190
1191 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1193
1194 let start = std::time::Instant::now();
1201 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1202 .err()
1203 .expect("an attempt inside the backoff window must be refused");
1204 assert!(
1205 throttled.contains("wait"),
1206 "expected a rate-limit refusal, got: {}",
1207 throttled
1208 );
1209 assert!(
1210 start.elapsed() < Duration::from_secs(1),
1211 "the throttle should refuse immediately rather than block the caller"
1212 );
1213
1214 std::thread::sleep(Duration::from_millis(2_100));
1217 let correct = EncryptionKey::load(&key_file, passphrase);
1218 assert!(
1219 correct.is_ok(),
1220 "the correct passphrase should be accepted once the window passes: {:?}",
1221 correct.as_ref().err()
1222 );
1223
1224 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1227 .err()
1228 .expect("a wrong passphrase must still fail");
1229 assert!(
1230 !after_reset.contains("wait"),
1231 "a successful load should reset the counter, got: {}",
1232 after_reset
1233 );
1234
1235 let _ = fs::remove_file(&key_file);
1236 }
1237
1238 #[test]
1247 fn test_nonces_do_not_repeat_across_engines() {
1248 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1249
1250 let mut nonces = std::collections::HashSet::new();
1251 let mut leading_zero_runs = 0;
1252
1253 for _ in 0..64 {
1254 let engine = EncryptionEngine::new(&key).unwrap();
1256 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1257 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1258
1259 if nonce[..8] == [0u8; 8] {
1260 leading_zero_runs += 1;
1261 }
1262 assert!(
1263 nonces.insert(nonce),
1264 "a nonce repeated across engines, which breaks AES-GCM"
1265 );
1266 }
1267
1268 assert!(
1270 leading_zero_runs <= 1,
1271 "{} of 64 nonces began with eight zero bytes, which means the \
1272 counter is resetting rather than the nonce being random",
1273 leading_zero_runs
1274 );
1275 }
1276}