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 restrict_to_owner(path: &Path) -> Result<(), String> {
142 #[cfg(unix)]
143 {
144 use std::os::unix::fs::PermissionsExt;
145 let mut perms = fs::metadata(path)
146 .map_err(|e| format!("Failed to read permissions: {}", e))?
147 .permissions();
148 perms.set_mode(0o600);
149 fs::set_permissions(path, perms)
150 .map_err(|e| format!("Failed to restrict permissions: {}", e))?;
151 }
152
153 #[cfg(windows)]
154 {
155 let mut perms = fs::metadata(path)
156 .map_err(|e| format!("Failed to read permissions: {}", e))?
157 .permissions();
158 perms.set_readonly(true);
159 fs::set_permissions(path, perms)
160 .map_err(|e| format!("Failed to restrict permissions: {}", e))?;
161 }
162
163 Ok(())
164}
165
166fn allow_replacement(path: &Path) -> Result<(), String> {
172 #[cfg(windows)]
173 {
174 if path.exists() {
175 let mut perms = fs::metadata(path)
176 .map_err(|e| format!("Failed to read permissions: {}", e))?
177 .permissions();
178 #[allow(clippy::permissions_set_readonly_false)]
183 perms.set_readonly(false);
184 fs::set_permissions(path, perms)
185 .map_err(|e| format!("Failed to clear read-only attribute: {}", e))?;
186 }
187 }
188
189 #[cfg(not(windows))]
190 let _ = path;
191
192 Ok(())
193}
194
195fn derived_key_id(key_file: &str, passphrase: &str) -> String {
201 use sha3::{Digest, Sha3_256};
202 let mut hasher = Sha3_256::new();
203 hasher.update(key_file.as_bytes());
204 hasher.update([0u8]); hasher.update(passphrase.as_bytes());
206 hex::encode(hasher.finalize())
207}
208
209fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
211 DERIVED_KEYS.lock().ok()?.get(id).cloned()
212}
213
214fn remember_derived_key(id: String, key: std::sync::Arc<EncryptionKey>) {
216 if let Ok(mut keys) = DERIVED_KEYS.lock() {
217 keys.insert(id, key);
218 }
219}
220
221fn check_rate_limit(repo_path: &str) -> Result<(), String> {
224 let mut attempts = FAILED_ATTEMPTS
225 .lock()
226 .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
227 let tracker = attempts
228 .entry(repo_path.to_string())
229 .or_insert_with(|| FailedAttemptTracker {
230 count: 0,
231 last_attempt: SystemTime::now(),
232 lockout_until: None,
233 });
234
235 if let Some(lockout) = tracker.lockout_until {
237 if SystemTime::now() < lockout {
238 let remaining = lockout
239 .duration_since(SystemTime::now())
240 .unwrap_or(Duration::from_secs(0));
241 return Err(format!(
242 "Too many failed attempts. Please wait {} seconds before trying again.",
243 remaining.as_secs()
244 ));
245 }
246 tracker.lockout_until = None;
248 tracker.count = 0;
249 }
250
251 if tracker.count > 0 {
253 let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
254 if let Ok(elapsed) = tracker.last_attempt.elapsed() {
255 if elapsed < delay {
256 let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
257 return Err(format!(
258 "Please wait {} seconds between passphrase attempts.",
259 remaining
260 ));
261 }
262 }
263 }
264
265 Ok(())
266}
267
268fn record_failed_attempt(repo_path: &str) {
270 let Ok(mut attempts) = FAILED_ATTEMPTS.lock() else {
271 return;
272 };
273 let tracker = attempts
274 .entry(repo_path.to_string())
275 .or_insert_with(|| FailedAttemptTracker {
276 count: 0,
277 last_attempt: SystemTime::now(),
278 lockout_until: None,
279 });
280
281 tracker.count += 1;
282 tracker.last_attempt = SystemTime::now();
283
284 if tracker.count >= 5 {
286 tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
287 eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
288 }
289}
290
291fn clear_failed_attempts(repo_path: &str) {
293 if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
294 attempts.remove(repo_path);
295 }
296}
297
298#[derive(ZeroizeOnDrop)]
300#[allow(unused_assignments)]
301pub struct EncryptionKey {
302 key_bytes: [u8; KEY_SIZE],
303 #[zeroize(skip)]
305 salt: [u8; SALT_SIZE],
306}
307
308impl EncryptionKey {
309 pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
311 #[cfg(not(test))]
313 validate_passphrase_strength(passphrase)?;
314 #[cfg(test)]
315 if !passphrase.starts_with("test-") {
316 validate_passphrase_strength(passphrase)?;
317 }
318
319 if salt.len() != SALT_SIZE {
320 return Err(format!(
321 "Invalid salt size: expected {}, got {}",
322 SALT_SIZE,
323 salt.len()
324 ));
325 }
326
327 let mut key_bytes = [0u8; KEY_SIZE];
328 pbkdf2_hmac::<Sha512>(
329 passphrase.as_bytes(),
330 salt,
331 PBKDF2_ITERATIONS,
332 &mut key_bytes,
333 );
334
335 let mut salt_array = [0u8; SALT_SIZE];
336 salt_array.copy_from_slice(salt);
337
338 Ok(EncryptionKey {
339 key_bytes,
340 salt: salt_array,
341 })
342 }
343
344 pub fn generate_salt() -> [u8; SALT_SIZE] {
346 use aes_gcm::aead::rand_core::RngCore;
347 let mut salt = [0u8; SALT_SIZE];
348 OsRng.fill_bytes(&mut salt);
349 salt
350 }
351
352 pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
356 let key_file_str = key_file.to_string_lossy().to_string();
358 #[cfg(not(test))]
359 check_rate_limit(&key_file_str)?;
360 #[cfg(test)]
361 if !passphrase.starts_with("test-") {
362 check_rate_limit(&key_file_str)?;
363 }
364
365 if !key_file.exists() {
366 return Err(
367 "Encryption key file not found. Initialize repository with encryption first."
368 .to_string(),
369 );
370 }
371
372 let encrypted_data =
373 fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
374
375 if encrypted_data.len() < SALT_SIZE + 1 {
376 return Err("Invalid key file format (too short)".to_string());
377 }
378
379 let salt = &encrypted_data[0..SALT_SIZE];
381 let version = encrypted_data[SALT_SIZE];
382
383 if version != ENCRYPTION_VERSION {
384 return Err(format!("Unsupported key file version: {}", version));
385 }
386
387 if encrypted_data.len() == SALT_SIZE + 1 {
389 let key = Self::from_passphrase(passphrase, salt)?;
391 clear_failed_attempts(&key_file_str);
393 return Ok(key);
394 }
395
396 if encrypted_data.len() < SALT_SIZE + 1 + 32 {
397 return Err("Invalid key file format (unexpected size)".to_string());
398 }
399
400 let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
401
402 let key = Self::from_passphrase(passphrase, salt)?;
404
405 use sha2::{Digest, Sha256};
407 let mut hasher = Sha256::new();
408 hasher.update(b"lit-passphrase-verification-v1");
409 hasher.update(&key.key_bytes);
410 let verification_hash = hasher.finalize();
411
412 use subtle::ConstantTimeEq;
414 if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
415 #[cfg(not(test))]
417 record_failed_attempt(&key_file_str);
418 #[cfg(test)]
419 if !passphrase.starts_with("test-") {
420 record_failed_attempt(&key_file_str);
421 }
422 std::thread::sleep(std::time::Duration::from_millis(100));
424 return Err("Invalid passphrase".to_string());
425 }
426
427 clear_failed_attempts(&key_file_str);
429 Ok(key)
430 }
431
432 pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
436 let expanded = shellexpand::tilde(key_file_str);
437 let key_file = Path::new(expanded.as_ref());
438
439 use sha2::{Digest, Sha256};
441 let mut hasher = Sha256::new();
442 hasher.update(b"lit-passphrase-verification-v1");
443 hasher.update(self.key_bytes);
444 let verification_hash = hasher.finalize();
445
446 if let Some(parent) = key_file.parent() {
448 fs::create_dir_all(parent)
449 .map_err(|e| format!("Failed to create key directory: {}", e))?;
450 }
451
452 let mut data = Vec::new();
454 data.extend_from_slice(&self.salt);
455 data.push(ENCRYPTION_VERSION);
456 data.extend_from_slice(&verification_hash);
457
458 let temp_file = key_file.with_extension("tmp");
460 fs::write(&temp_file, &data)
461 .map_err(|e| format!("Failed to write temp key file: {}", e))?;
462
463 restrict_to_owner(&temp_file)?;
471
472 allow_replacement(key_file)?;
477
478 fs::rename(&temp_file, key_file)
479 .map_err(|e| format!("Failed to rename key file: {}", e))?;
480
481 Ok(())
482 }
483
484 fn as_bytes(&self) -> &[u8; KEY_SIZE] {
486 &self.key_bytes
487 }
488}
489
490const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
493
494pub struct EncryptionEngine {
497 cipher: Aes256Gcm,
498 nonce_counter: AtomicU64,
500}
501
502impl EncryptionEngine {
503 pub fn new(key: &EncryptionKey) -> Result<Self, String> {
505 let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
506 .map_err(|e| format!("Failed to create cipher: {}", e))?;
507
508 Ok(EncryptionEngine {
509 cipher,
510 nonce_counter: AtomicU64::new(0),
511 })
512 }
513
514 #[allow(deprecated)]
519 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
520 let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
527 if count >= MAX_ENCRYPTIONS_PER_KEY {
528 return Err(format!(
529 "Encryption limit exceeded ({} operations). Key rotation required for security.",
530 MAX_ENCRYPTIONS_PER_KEY
531 ));
532 }
533
534 use aes_gcm::aead::rand_core::RngCore;
551 let mut nonce_bytes = [0u8; NONCE_SIZE];
552 OsRng.fill_bytes(&mut nonce_bytes);
553 let nonce = Nonce::from_slice(&nonce_bytes);
554
555 let ciphertext = self
557 .cipher
558 .encrypt(nonce, plaintext)
559 .map_err(|e| format!("Encryption failed: {}", e))?;
560
561 let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
563 output.push(ENCRYPTION_VERSION);
564 output.extend_from_slice(&nonce_bytes);
565 output.extend_from_slice(&ciphertext);
566
567 Ok(output)
568 }
569
570 #[allow(deprecated)]
572 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
573 if encrypted.len() < 1 + NONCE_SIZE {
574 return Err("Invalid encrypted data: too short".to_string());
575 }
576
577 let version = encrypted[0];
579 if version != ENCRYPTION_VERSION {
580 return Err(format!("Unsupported encryption version: {}", version));
581 }
582
583 let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
585 let nonce = Nonce::from_slice(nonce_bytes);
586
587 let ciphertext = &encrypted[1 + NONCE_SIZE..];
589
590 let plaintext = self
592 .cipher
593 .decrypt(nonce, ciphertext)
594 .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
595
596 Ok(plaintext)
597 }
598}
599
600impl CachedPassphrase {
602 fn is_valid(&self) -> bool {
604 SystemTime::now() < self.expires_at
605 }
606}
607
608pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
611 let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
612 let expires_at = SystemTime::now() + timeout;
613
614 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
615 cache.insert(
616 repo_path.to_string(),
617 CachedPassphrase {
618 passphrase: Zeroizing::new(passphrase),
619 expires_at,
620 },
621 );
622 }
623}
624
625pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
628 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
629 if let Some(entry) = cache.get(repo_path) {
630 if entry.is_valid() {
631 return Some(entry.passphrase.clone());
632 } else {
633 cache.remove(repo_path);
635 }
636 }
637 }
638 None
639}
640
641pub fn clear_passphrase_cache() {
643 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
644 cache.clear();
645 }
646}
647
648pub fn clear_cached_passphrase(repo_path: &str) {
650 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
651 cache.remove(repo_path);
652 }
653}
654
655fn get_passphrase_non_interactive(
661 repo_path: &str,
662 config: &EncryptionConfig,
663) -> Option<Zeroizing<String>> {
664 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
666 if !pass.is_empty() {
667 return Some(Zeroizing::new(pass));
668 }
669 }
670
671 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
673 if let Ok(pass) = std::fs::read_to_string(&path) {
674 let pass = pass
675 .trim_end_matches('\n')
676 .trim_end_matches('\r')
677 .to_string();
678 if !pass.is_empty() {
679 return Some(Zeroizing::new(pass));
680 }
681 }
682 }
683
684 if config.cache_timeout_secs > 0 {
686 if let Some(cached) = get_cached_passphrase(repo_path) {
687 return Some(cached);
688 }
689 }
690
691 None
692}
693
694pub fn prompt_for_passphrase(
700 repo_path: &str,
701 config: &EncryptionConfig,
702 prompt_text: &str,
703) -> Result<Zeroizing<String>, String> {
704 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
706 return Ok(pass);
707 }
708
709 if !std::io::stdin().is_terminal() {
712 return Err(
713 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
714 LIT_PASSPHRASE_FILE"
715 .to_string(),
716 );
717 }
718
719 rpassword::prompt_password(prompt_text)
721 .map(Zeroizing::new)
722 .map_err(|e| format!("Failed to read passphrase: {}", e))
723}
724
725const MIN_PASSPHRASE_LENGTH: usize = 16;
727
728fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
734 #[cfg(test)]
736 if passphrase.starts_with("test-") {
737 return Ok(());
738 }
739
740 if passphrase.len() < MIN_PASSPHRASE_LENGTH {
741 return Err(format!(
742 "Passphrase must be at least {} characters (recommended: 20+)",
743 MIN_PASSPHRASE_LENGTH
744 ));
745 }
746
747 let has_upper = passphrase.chars().any(|c| c.is_uppercase());
749 let has_lower = passphrase.chars().any(|c| c.is_lowercase());
750 let has_digit = passphrase.chars().any(|c| c.is_numeric());
751 let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
752
753 let complexity_count = [has_upper, has_lower, has_digit, has_special]
754 .iter()
755 .filter(|&&x| x)
756 .count();
757
758 if complexity_count < 3 {
759 return Err(
760 "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
761 .to_string(),
762 );
763 }
764
765 Ok(())
766}
767
768pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
772 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
774 if !pass.is_empty() {
775 validate_passphrase_strength(&pass)?;
776 return Ok(Zeroizing::new(pass));
777 }
778 }
779
780 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
782 if let Ok(pass) = std::fs::read_to_string(&path) {
783 let pass = pass
784 .trim_end_matches('\n')
785 .trim_end_matches('\r')
786 .to_string();
787 if !pass.is_empty() {
788 validate_passphrase_strength(&pass)?;
789 return Ok(Zeroizing::new(pass));
790 }
791 }
792 }
793
794 if !std::io::stdin().is_terminal() {
796 return Err(
797 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
798 LIT_PASSPHRASE_FILE"
799 .to_string(),
800 );
801 }
802
803 let pass1 = rpassword::prompt_password(prompt_text)
805 .map_err(|e| format!("Failed to read passphrase: {}", e))?;
806
807 let pass2 = rpassword::prompt_password("Confirm passphrase: ")
808 .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
809
810 if pass1 != pass2 {
811 return Err("Passphrases do not match".to_string());
812 }
813
814 validate_passphrase_strength(&pass1)?;
815
816 Ok(Zeroizing::new(pass1))
817}
818
819pub struct EncryptionManager {
821 config: EncryptionConfig,
822 engine: Option<EncryptionEngine>,
823 repo_path: Option<String>,
824}
825
826impl EncryptionManager {
827 pub fn new(config: EncryptionConfig) -> Self {
829 EncryptionManager {
830 config,
831 engine: None,
832 repo_path: None,
833 }
834 }
835
836 pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
849 let mut manager = EncryptionManager::new(config);
850 if !manager.config.enabled {
851 return manager;
852 }
853
854 let repo = repo_path.to_string_lossy().to_string();
855 let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
856 return manager;
857 };
858
859 manager.repo_path = Some(repo.clone());
860 if let Err(e) = manager.initialize(&passphrase) {
861 eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
862 return manager;
863 }
864
865 if manager.config.cache_timeout_secs > 0 {
866 let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
867 cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
868 }
869
870 manager
871 }
872
873 pub fn is_encrypted_payload(data: &[u8]) -> bool {
880 data.first() == Some(&ENCRYPTION_VERSION)
881 }
882
883 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
885 if !self.config.enabled {
886 return Ok(());
887 }
888
889 let expanded = shellexpand::tilde(&self.config.key_file);
890 let key_file = Path::new(expanded.as_ref());
891
892 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
903 if let Some(key) = cached_derived_key(&cache_id) {
904 self.engine = Some(EncryptionEngine::new(&key)?);
905 return Ok(());
906 }
907
908 let key = if key_file.exists() {
910 EncryptionKey::load(key_file, passphrase)?
911 } else {
912 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
913 key.save(&self.config.key_file, passphrase)?;
914 key
915 };
916
917 let key = std::sync::Arc::new(key);
918 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
919
920 self.engine = Some(EncryptionEngine::new(&key)?);
922
923 Ok(())
924 }
925
926 pub fn initialize_with_cache(
928 &mut self,
929 repo_path: &str,
930 passphrase: Option<&str>,
931 ) -> Result<(), String> {
932 if !self.config.enabled {
933 return Ok(());
934 }
935
936 self.repo_path = Some(repo_path.to_string());
937
938 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
940 Zeroizing::new(pass.to_string())
941 } else if let Some(cached) = get_cached_passphrase(repo_path) {
942 cached
943 } else {
944 return Err("No passphrase provided and no valid cached passphrase found".to_string());
945 };
946
947 self.initialize(&actual_passphrase)?;
949
950 if self.config.cache_timeout_secs > 0 {
952 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
953 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
954 }
955
956 Ok(())
957 }
958
959 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
961 if !self.config.enabled {
962 return Ok(plaintext.to_vec());
963 }
964
965 match &self.engine {
966 Some(engine) => engine.encrypt(plaintext),
967 None => Err(
968 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
969 ),
970 }
971 }
972
973 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
975 if !self.config.enabled {
976 return Ok(encrypted.to_vec());
977 }
978
979 match &self.engine {
980 Some(engine) => {
981 if encrypted
988 .first()
989 .is_some_and(|version| *version != ENCRYPTION_VERSION)
990 {
991 return Err(
992 "This data has no Lit encryption header. Encryption cannot be \
993 enabled for a repository that already contains unencrypted \
994 commits — start a new encrypted repository and import into it."
995 .to_string(),
996 );
997 }
998 engine.decrypt(encrypted)
999 }
1000 None => Err(
1001 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1002 ),
1003 }
1004 }
1005
1006 pub fn is_enabled(&self) -> bool {
1008 self.config.enabled
1009 }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015
1016 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1024
1025 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1026 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1027 }
1028
1029 fn test_key_path(label: &str) -> std::path::PathBuf {
1038 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1039 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1040 let path = std::env::temp_dir().join(format!(
1041 "lit_enc_test_{}_{}_{}.key",
1042 std::process::id(),
1043 label,
1044 n
1045 ));
1046 let _ = fs::remove_file(&path);
1047 path
1048 }
1049
1050 #[test]
1051 fn test_key_derivation() {
1052 let passphrase = "test-passphrase-12345";
1053 let salt = EncryptionKey::generate_salt();
1054
1055 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1056 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1057
1058 assert_eq!(key1.as_bytes(), key2.as_bytes());
1060 }
1061
1062 #[test]
1063 fn test_encryption_decryption() {
1064 let passphrase = "test-secure-passphrase";
1065 let salt = EncryptionKey::generate_salt();
1066 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1067
1068 let engine = EncryptionEngine::new(&key).unwrap();
1069
1070 let plaintext = b"Hello, this is secret data!";
1071
1072 let encrypted = engine.encrypt(plaintext).unwrap();
1074
1075 assert_ne!(encrypted.as_slice(), plaintext);
1077
1078 let decrypted = engine.decrypt(&encrypted).unwrap();
1080
1081 assert_eq!(decrypted.as_slice(), plaintext);
1083 }
1084
1085 #[test]
1086 fn test_encryption_nonce_randomness() {
1087 let passphrase = "test-passphrase";
1088 let salt = EncryptionKey::generate_salt();
1089 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1090
1091 let engine = EncryptionEngine::new(&key).unwrap();
1092
1093 let plaintext = b"Same data";
1094
1095 let encrypted1 = engine.encrypt(plaintext).unwrap();
1097 let encrypted2 = engine.encrypt(plaintext).unwrap();
1098
1099 assert_ne!(encrypted1, encrypted2);
1101
1102 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1104 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1105 }
1106
1107 #[test]
1108 fn test_tampering_detection() {
1109 let passphrase = "test-passphrase";
1110 let salt = EncryptionKey::generate_salt();
1111 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1112
1113 let engine = EncryptionEngine::new(&key).unwrap();
1114
1115 let plaintext = b"Secret data";
1116 let mut encrypted = engine.encrypt(plaintext).unwrap();
1117
1118 let len = encrypted.len();
1120 encrypted[len - 1] ^= 0x01;
1121
1122 assert!(engine.decrypt(&encrypted).is_err());
1124 }
1125
1126 #[test]
1127 fn test_encryption_manager_disabled() {
1128 let config = EncryptionConfig {
1129 enabled: false,
1130 ..Default::default()
1131 };
1132
1133 let manager = EncryptionManager::new(config);
1134
1135 let data = b"Some data";
1136
1137 assert_eq!(manager.encrypt(data).unwrap(), data);
1139 assert_eq!(manager.decrypt(data).unwrap(), data);
1140 }
1141
1142 #[test]
1143 fn test_passphrase_caching() {
1144 let _guard = cache_test_guard();
1145 let repo_path = "/tmp/test-repo";
1146 let passphrase = "cache-test-passphrase".to_string();
1147
1148 clear_passphrase_cache();
1150
1151 assert!(get_cached_passphrase(repo_path).is_none());
1153
1154 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1156
1157 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1159
1160 clear_cached_passphrase(repo_path);
1162 assert!(get_cached_passphrase(repo_path).is_none());
1163 }
1164
1165 #[test]
1166 fn test_passphrase_cache_expiration() {
1167 let _guard = cache_test_guard();
1168 let repo_path = "/tmp/test-repo-expire";
1169 let passphrase = "expire-test".to_string();
1170
1171 clear_passphrase_cache();
1172
1173 cache_passphrase(
1180 repo_path,
1181 passphrase.clone(),
1182 Some(Duration::from_millis(200)),
1183 );
1184
1185 std::thread::sleep(Duration::from_millis(600));
1186
1187 assert!(get_cached_passphrase(repo_path).is_none());
1189 }
1190
1191 #[test]
1192 fn test_passphrase_cache_multiple_repos() {
1193 let _guard = cache_test_guard();
1194 let repo1 = "/tmp/multi-cache-repo1";
1195 let repo2 = "/tmp/multi-cache-repo2";
1196 let pass1 = "password1".to_string();
1197 let pass2 = "password2".to_string();
1198
1199 clear_passphrase_cache();
1200
1201 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1203 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1204
1205 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1207 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1208 }
1209
1210 #[test]
1211 fn test_encryption_manager_with_cache() {
1212 use std::env;
1213
1214 let _guard = cache_test_guard();
1215
1216 let key_file = test_key_path("manager_cache");
1217
1218 let temp_dir = env::temp_dir();
1219 let repo_path = temp_dir.join("test-cache-manager");
1220 let repo_str = repo_path.to_str().unwrap();
1221
1222 clear_passphrase_cache();
1223
1224 let config = EncryptionConfig {
1225 enabled: true,
1226 key_file: key_file.to_string_lossy().into_owned(),
1227 cache_timeout_secs: 300, ..Default::default()
1229 };
1230
1231 let mut manager = EncryptionManager::new(config);
1232 let passphrase = "test-cache-manager-pass";
1233
1234 manager
1236 .initialize_with_cache(repo_str, Some(passphrase))
1237 .unwrap();
1238
1239 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1241
1242 let mut manager2 = EncryptionManager::new(manager.config.clone());
1244 manager2.initialize_with_cache(repo_str, None).unwrap();
1245
1246 clear_passphrase_cache();
1248 let _ = fs::remove_file(&key_file);
1249 }
1250
1251 #[test]
1257 #[ignore]
1258 fn test_rate_limiting() {
1259 let key_file = test_key_path("rate_limiting");
1260 let key_file_str = key_file.to_string_lossy().into_owned();
1261
1262 let passphrase = "correct-passphrase-1234567890";
1265 let salt = EncryptionKey::generate_salt();
1266 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1267 key.save(&key_file_str, passphrase).unwrap();
1268
1269 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1271
1272 let start = std::time::Instant::now();
1279 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1280 .err()
1281 .expect("an attempt inside the backoff window must be refused");
1282 assert!(
1283 throttled.contains("wait"),
1284 "expected a rate-limit refusal, got: {}",
1285 throttled
1286 );
1287 assert!(
1288 start.elapsed() < Duration::from_secs(1),
1289 "the throttle should refuse immediately rather than block the caller"
1290 );
1291
1292 std::thread::sleep(Duration::from_millis(2_100));
1295 let correct = EncryptionKey::load(&key_file, passphrase);
1296 assert!(
1297 correct.is_ok(),
1298 "the correct passphrase should be accepted once the window passes: {:?}",
1299 correct.as_ref().err()
1300 );
1301
1302 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1305 .err()
1306 .expect("a wrong passphrase must still fail");
1307 assert!(
1308 !after_reset.contains("wait"),
1309 "a successful load should reset the counter, got: {}",
1310 after_reset
1311 );
1312
1313 let _ = fs::remove_file(&key_file);
1314 }
1315
1316 #[test]
1325 fn test_nonces_do_not_repeat_across_engines() {
1326 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1327
1328 let mut nonces = std::collections::HashSet::new();
1329 let mut leading_zero_runs = 0;
1330
1331 for _ in 0..64 {
1332 let engine = EncryptionEngine::new(&key).unwrap();
1334 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1335 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1336
1337 if nonce[..8] == [0u8; 8] {
1338 leading_zero_runs += 1;
1339 }
1340 assert!(
1341 nonces.insert(nonce),
1342 "a nonce repeated across engines, which breaks AES-GCM"
1343 );
1344 }
1345
1346 assert!(
1348 leading_zero_runs <= 1,
1349 "{} of 64 nonces began with eight zero bytes, which means the \
1350 counter is resetting rather than the nonce being random",
1351 leading_zero_runs
1352 );
1353 }
1354}
1355
1356#[cfg(test)]
1357mod key_file_permission_tests {
1358 use super::*;
1359
1360 #[test]
1367 fn test_key_file_can_be_saved_over() {
1368 let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1369 let _ = fs::remove_file(&path);
1370 let path_str = path.to_string_lossy().to_string();
1371
1372 let first =
1373 EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1374 first
1375 .save(&path_str, "FirstPassphrase!123")
1376 .expect("first save should succeed");
1377
1378 let second =
1379 EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1380 second
1381 .save(&path_str, "SecondPassphrase!234")
1382 .expect("saving over an existing key file should succeed, as rotate-key does");
1383
1384 let stored = fs::read(&path).unwrap();
1386 assert_eq!(
1387 &stored[..SALT_SIZE],
1388 &[2u8; SALT_SIZE],
1389 "the rewrite should have taken effect"
1390 );
1391
1392 let _ = fs::remove_file(&path);
1393 }
1394}