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
133pub(crate) fn restrict_to_owner(path: &Path) -> Result<(), String> {
139 #[cfg(unix)]
140 {
141 use std::os::unix::fs::PermissionsExt;
142 let mut perms = fs::metadata(path)
143 .map_err(|e| format!("Failed to read permissions: {}", e))?
144 .permissions();
145 perms.set_mode(0o600);
146 fs::set_permissions(path, perms)
147 .map_err(|e| format!("Failed to restrict permissions: {}", e))?;
148 }
149
150 #[cfg(windows)]
151 windows_restrict_to_owner(path)?;
152
153 Ok(())
154}
155
156#[cfg(windows)]
166fn windows_restrict_to_owner(path: &Path) -> Result<(), String> {
167 use std::os::windows::ffi::OsStrExt;
168 use windows::core::PWSTR;
169 use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
170 use windows::Win32::Security::Authorization::{
171 SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, SE_FILE_OBJECT,
172 TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
173 };
174 use windows::Win32::Security::{
175 GetTokenInformation, TokenUser, ACL, DACL_SECURITY_INFORMATION, NO_INHERITANCE,
176 PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER,
177 };
178 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
179
180 let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
182 wide.push(0);
183
184 unsafe {
185 let mut token = HANDLE::default();
187 OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)
188 .map_err(|e| format!("Failed to open process token: {}", e))?;
189
190 let mut needed = 0u32;
191 let _ = GetTokenInformation(token, TokenUser, None, 0, &mut needed);
192 let mut buffer = vec![0u8; needed as usize];
193 let info_result = GetTokenInformation(
194 token,
195 TokenUser,
196 Some(buffer.as_mut_ptr() as *mut _),
197 needed,
198 &mut needed,
199 );
200 let _ = CloseHandle(token);
201 info_result.map_err(|e| format!("Failed to read token user: {}", e))?;
202
203 let user_sid: PSID = (*(buffer.as_ptr() as *const TOKEN_USER)).User.Sid;
204
205 let access = EXPLICIT_ACCESS_W {
207 grfAccessPermissions: 0x001F_01FF, grfAccessMode: SET_ACCESS,
209 grfInheritance: NO_INHERITANCE,
210 Trustee: TRUSTEE_W {
211 pMultipleTrustee: std::ptr::null_mut(),
212 MultipleTrusteeOperation: Default::default(),
213 TrusteeForm: TRUSTEE_IS_SID,
214 TrusteeType: TRUSTEE_IS_USER,
215 ptstrName: PWSTR(user_sid.0 as *mut u16),
216 },
217 };
218
219 let mut acl: *mut ACL = std::ptr::null_mut();
220 let entries = [access];
221 let status = SetEntriesInAclW(Some(&entries), None, &mut acl);
222 if status.is_err() {
223 return Err(format!("Failed to build ACL: {:?}", status));
224 }
225
226 let status = SetNamedSecurityInfoW(
229 PWSTR(wide.as_mut_ptr()),
230 SE_FILE_OBJECT,
231 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
232 PSID::default(),
233 PSID::default(),
234 Some(acl),
235 None,
236 );
237
238 if !acl.is_null() {
239 let _ = LocalFree(HLOCAL(acl as *mut _));
240 }
241
242 if status.is_err() {
243 return Err(format!("Failed to set file DACL: {:?}", status));
244 }
245
246 let _ = PSECURITY_DESCRIPTOR::default();
247 }
248
249 Ok(())
250}
251
252fn allow_replacement(path: &Path) -> Result<(), String> {
258 #[cfg(windows)]
259 {
260 if path.exists() {
261 let mut perms = fs::metadata(path)
262 .map_err(|e| format!("Failed to read permissions: {}", e))?
263 .permissions();
264 #[allow(clippy::permissions_set_readonly_false)]
269 perms.set_readonly(false);
270 fs::set_permissions(path, perms)
271 .map_err(|e| format!("Failed to clear read-only attribute: {}", e))?;
272 }
273 }
274
275 #[cfg(not(windows))]
276 let _ = path;
277
278 Ok(())
279}
280
281fn derived_key_id(key_file: &str, passphrase: &str) -> String {
287 use sha3::{Digest, Sha3_256};
288 let mut hasher = Sha3_256::new();
289 hasher.update(key_file.as_bytes());
290 hasher.update([0u8]); hasher.update(passphrase.as_bytes());
292 hex::encode(hasher.finalize())
293}
294
295fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
297 DERIVED_KEYS.lock().ok()?.get(id).cloned()
298}
299
300fn remember_derived_key(id: String, key: std::sync::Arc<EncryptionKey>) {
302 if let Ok(mut keys) = DERIVED_KEYS.lock() {
303 keys.insert(id, key);
304 }
305}
306
307fn throttle_state_path(repo_path: &str) -> std::path::PathBuf {
309 std::path::PathBuf::from(format!("{}.throttle", repo_path))
310}
311
312#[derive(Serialize, Deserialize, Default)]
315struct PersistedThrottle {
316 count: u32,
317 last_attempt_secs: u64,
318 lockout_until_secs: Option<u64>,
319}
320
321fn to_unix(t: SystemTime) -> u64 {
322 t.duration_since(SystemTime::UNIX_EPOCH)
323 .map(|d| d.as_secs())
324 .unwrap_or(0)
325}
326
327fn from_unix(secs: u64) -> SystemTime {
328 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
329}
330
331fn load_throttle(repo_path: &str) -> FailedAttemptTracker {
336 let stored: Option<PersistedThrottle> = fs::read(throttle_state_path(repo_path))
337 .ok()
338 .and_then(|raw| serde_json::from_slice(&raw).ok());
339
340 match stored {
341 Some(s) => FailedAttemptTracker {
342 count: s.count,
343 last_attempt: from_unix(s.last_attempt_secs),
344 lockout_until: s.lockout_until_secs.map(from_unix),
345 },
346 None => FailedAttemptTracker {
347 count: 0,
348 last_attempt: SystemTime::now(),
349 lockout_until: None,
350 },
351 }
352}
353
354fn store_throttle(repo_path: &str, tracker: &FailedAttemptTracker) {
360 let state = PersistedThrottle {
361 count: tracker.count,
362 last_attempt_secs: to_unix(tracker.last_attempt),
363 lockout_until_secs: tracker.lockout_until.map(to_unix),
364 };
365
366 let path = throttle_state_path(repo_path);
367 if let Ok(raw) = serde_json::to_vec(&state) {
368 if fs::write(&path, raw).is_ok() {
369 let _ = restrict_to_owner(&path);
372 }
373 }
374}
375
376fn check_rate_limit(repo_path: &str) -> Result<(), String> {
389 let _serialize = FAILED_ATTEMPTS
390 .lock()
391 .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
392 let mut tracker = load_throttle(repo_path);
393
394 if let Some(lockout) = tracker.lockout_until {
396 if SystemTime::now() < lockout {
397 let remaining = lockout
398 .duration_since(SystemTime::now())
399 .unwrap_or(Duration::from_secs(0));
400 return Err(format!(
401 "Too many failed attempts. Please wait {} seconds before trying again.",
402 remaining.as_secs()
403 ));
404 }
405 tracker.lockout_until = None;
407 tracker.count = 0;
408 store_throttle(repo_path, &tracker);
409 }
410
411 if tracker.count > 0 {
413 let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
414 if let Ok(elapsed) = tracker.last_attempt.elapsed() {
415 if elapsed < delay {
416 let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
417 return Err(format!(
418 "Please wait {} seconds between passphrase attempts.",
419 remaining
420 ));
421 }
422 }
423 }
424
425 Ok(())
426}
427
428fn record_failed_attempt(repo_path: &str) {
430 let Ok(_serialize) = FAILED_ATTEMPTS.lock() else {
431 return;
432 };
433 let mut tracker = load_throttle(repo_path);
434
435 tracker.count += 1;
436 tracker.last_attempt = SystemTime::now();
437
438 if tracker.count >= 5 {
440 tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
441 eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
442 }
443
444 store_throttle(repo_path, &tracker);
445}
446
447fn clear_failed_attempts(repo_path: &str) {
449 if let Ok(_serialize) = FAILED_ATTEMPTS.lock() {
450 let _ = fs::remove_file(throttle_state_path(repo_path));
453 }
454}
455
456#[derive(ZeroizeOnDrop)]
458#[allow(unused_assignments)]
459pub struct EncryptionKey {
460 key_bytes: [u8; KEY_SIZE],
461 #[zeroize(skip)]
463 salt: [u8; SALT_SIZE],
464}
465
466impl EncryptionKey {
467 pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
469 #[cfg(not(test))]
471 validate_passphrase_strength(passphrase)?;
472 #[cfg(test)]
473 if !passphrase.starts_with("test-") {
474 validate_passphrase_strength(passphrase)?;
475 }
476
477 if salt.len() != SALT_SIZE {
478 return Err(format!(
479 "Invalid salt size: expected {}, got {}",
480 SALT_SIZE,
481 salt.len()
482 ));
483 }
484
485 let mut key_bytes = [0u8; KEY_SIZE];
486 pbkdf2_hmac::<Sha512>(
487 passphrase.as_bytes(),
488 salt,
489 PBKDF2_ITERATIONS,
490 &mut key_bytes,
491 );
492
493 let mut salt_array = [0u8; SALT_SIZE];
494 salt_array.copy_from_slice(salt);
495
496 Ok(EncryptionKey {
497 key_bytes,
498 salt: salt_array,
499 })
500 }
501
502 pub fn generate_salt() -> [u8; SALT_SIZE] {
504 use aes_gcm::aead::rand_core::RngCore;
505 let mut salt = [0u8; SALT_SIZE];
506 OsRng.fill_bytes(&mut salt);
507 salt
508 }
509
510 pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
514 let key_file_str = key_file.to_string_lossy().to_string();
516 #[cfg(not(test))]
517 check_rate_limit(&key_file_str)?;
518 #[cfg(test)]
519 if !passphrase.starts_with("test-") {
520 check_rate_limit(&key_file_str)?;
521 }
522
523 if !key_file.exists() {
524 return Err(
525 "Encryption key file not found. Initialize repository with encryption first."
526 .to_string(),
527 );
528 }
529
530 restrict_to_owner(key_file)?;
535
536 let encrypted_data =
537 fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
538
539 if encrypted_data.len() < SALT_SIZE + 1 {
540 return Err("Invalid key file format (too short)".to_string());
541 }
542
543 let salt = &encrypted_data[0..SALT_SIZE];
545 let version = encrypted_data[SALT_SIZE];
546
547 if version != ENCRYPTION_VERSION {
548 return Err(format!("Unsupported key file version: {}", version));
549 }
550
551 if encrypted_data.len() == SALT_SIZE + 1 {
553 let key = Self::from_passphrase(passphrase, salt)?;
555 clear_failed_attempts(&key_file_str);
557 return Ok(key);
558 }
559
560 if encrypted_data.len() < SALT_SIZE + 1 + 32 {
561 return Err("Invalid key file format (unexpected size)".to_string());
562 }
563
564 let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
565
566 let key = Self::from_passphrase(passphrase, salt)?;
568
569 use sha2::{Digest, Sha256};
571 let mut hasher = Sha256::new();
572 hasher.update(b"lit-passphrase-verification-v1");
573 hasher.update(&key.key_bytes);
574 let verification_hash = hasher.finalize();
575
576 use subtle::ConstantTimeEq;
578 if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
579 #[cfg(not(test))]
581 record_failed_attempt(&key_file_str);
582 #[cfg(test)]
583 if !passphrase.starts_with("test-") {
584 record_failed_attempt(&key_file_str);
585 }
586 std::thread::sleep(std::time::Duration::from_millis(100));
588 return Err("Invalid passphrase".to_string());
589 }
590
591 clear_failed_attempts(&key_file_str);
593 Ok(key)
594 }
595
596 pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
600 let expanded = shellexpand::tilde(key_file_str);
601 let key_file = Path::new(expanded.as_ref());
602
603 use sha2::{Digest, Sha256};
605 let mut hasher = Sha256::new();
606 hasher.update(b"lit-passphrase-verification-v1");
607 hasher.update(self.key_bytes);
608 let verification_hash = hasher.finalize();
609
610 if let Some(parent) = key_file.parent() {
612 fs::create_dir_all(parent)
613 .map_err(|e| format!("Failed to create key directory: {}", e))?;
614 }
615
616 let mut data = Vec::new();
618 data.extend_from_slice(&self.salt);
619 data.push(ENCRYPTION_VERSION);
620 data.extend_from_slice(&verification_hash);
621
622 let temp_file = key_file.with_extension("tmp");
624 fs::write(&temp_file, &data)
625 .map_err(|e| format!("Failed to write temp key file: {}", e))?;
626
627 restrict_to_owner(&temp_file)?;
635
636 allow_replacement(key_file)?;
641
642 fs::rename(&temp_file, key_file)
643 .map_err(|e| format!("Failed to rename key file: {}", e))?;
644
645 Ok(())
646 }
647
648 fn as_bytes(&self) -> &[u8; KEY_SIZE] {
650 &self.key_bytes
651 }
652}
653
654const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
657
658pub struct EncryptionEngine {
661 cipher: Aes256Gcm,
662 nonce_counter: AtomicU64,
664}
665
666impl EncryptionEngine {
667 pub fn new(key: &EncryptionKey) -> Result<Self, String> {
669 crate::crypto::fips::ensure_self_tests()?;
673
674 let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
675 .map_err(|e| format!("Failed to create cipher: {}", e))?;
676
677 Ok(EncryptionEngine {
678 cipher,
679 nonce_counter: AtomicU64::new(0),
680 })
681 }
682
683 #[allow(deprecated)]
688 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
689 let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
696 if count >= MAX_ENCRYPTIONS_PER_KEY {
697 return Err(format!(
698 "Encryption limit exceeded ({} operations). Key rotation required for security.",
699 MAX_ENCRYPTIONS_PER_KEY
700 ));
701 }
702
703 use aes_gcm::aead::rand_core::RngCore;
720 let mut nonce_bytes = [0u8; NONCE_SIZE];
721 OsRng.fill_bytes(&mut nonce_bytes);
722 let nonce = Nonce::from_slice(&nonce_bytes);
723
724 let ciphertext = self
726 .cipher
727 .encrypt(nonce, plaintext)
728 .map_err(|e| format!("Encryption failed: {}", e))?;
729
730 let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
732 output.push(ENCRYPTION_VERSION);
733 output.extend_from_slice(&nonce_bytes);
734 output.extend_from_slice(&ciphertext);
735
736 Ok(output)
737 }
738
739 #[allow(deprecated)]
741 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
742 if encrypted.len() < 1 + NONCE_SIZE {
743 return Err("Invalid encrypted data: too short".to_string());
744 }
745
746 let version = encrypted[0];
748 if version != ENCRYPTION_VERSION {
749 return Err(format!("Unsupported encryption version: {}", version));
750 }
751
752 let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
754 let nonce = Nonce::from_slice(nonce_bytes);
755
756 let ciphertext = &encrypted[1 + NONCE_SIZE..];
758
759 let plaintext = self
761 .cipher
762 .decrypt(nonce, ciphertext)
763 .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
764
765 Ok(plaintext)
766 }
767}
768
769impl CachedPassphrase {
771 fn is_valid(&self) -> bool {
773 SystemTime::now() < self.expires_at
774 }
775}
776
777pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
780 let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
781 let expires_at = SystemTime::now() + timeout;
782
783 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
784 cache.insert(
785 repo_path.to_string(),
786 CachedPassphrase {
787 passphrase: Zeroizing::new(passphrase),
788 expires_at,
789 },
790 );
791 }
792}
793
794pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
797 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
798 if let Some(entry) = cache.get(repo_path) {
799 if entry.is_valid() {
800 return Some(entry.passphrase.clone());
801 } else {
802 cache.remove(repo_path);
804 }
805 }
806 }
807 None
808}
809
810pub fn clear_passphrase_cache() {
812 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
813 cache.clear();
814 }
815}
816
817pub fn clear_cached_passphrase(repo_path: &str) {
819 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
820 cache.remove(repo_path);
821 }
822}
823
824fn get_passphrase_non_interactive(
830 repo_path: &str,
831 config: &EncryptionConfig,
832) -> Option<Zeroizing<String>> {
833 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
835 if !pass.is_empty() {
836 return Some(Zeroizing::new(pass));
837 }
838 }
839
840 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
842 if let Ok(pass) = std::fs::read_to_string(&path) {
843 let pass = pass
844 .trim_end_matches('\n')
845 .trim_end_matches('\r')
846 .to_string();
847 if !pass.is_empty() {
848 return Some(Zeroizing::new(pass));
849 }
850 }
851 }
852
853 if config.cache_timeout_secs > 0 {
855 if let Some(cached) = get_cached_passphrase(repo_path) {
856 return Some(cached);
857 }
858 }
859
860 if let Some(from_agent) = crate::crypto::agent::get(repo_path) {
866 return Some(from_agent);
867 }
868
869 None
870}
871
872pub fn prompt_for_passphrase(
878 repo_path: &str,
879 config: &EncryptionConfig,
880 prompt_text: &str,
881) -> Result<Zeroizing<String>, String> {
882 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
884 return Ok(pass);
885 }
886
887 if !std::io::stdin().is_terminal() {
890 return Err(
891 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
892 LIT_PASSPHRASE_FILE"
893 .to_string(),
894 );
895 }
896
897 rpassword::prompt_password(prompt_text)
899 .map(Zeroizing::new)
900 .map_err(|e| format!("Failed to read passphrase: {}", e))
901}
902
903const MIN_PASSPHRASE_LENGTH: usize = 16;
905
906fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
912 #[cfg(test)]
914 if passphrase.starts_with("test-") {
915 return Ok(());
916 }
917
918 if passphrase.len() < MIN_PASSPHRASE_LENGTH {
919 return Err(format!(
920 "Passphrase must be at least {} characters (recommended: 20+)",
921 MIN_PASSPHRASE_LENGTH
922 ));
923 }
924
925 let has_upper = passphrase.chars().any(|c| c.is_uppercase());
927 let has_lower = passphrase.chars().any(|c| c.is_lowercase());
928 let has_digit = passphrase.chars().any(|c| c.is_numeric());
929 let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
930
931 let complexity_count = [has_upper, has_lower, has_digit, has_special]
932 .iter()
933 .filter(|&&x| x)
934 .count();
935
936 if complexity_count < 3 {
937 return Err(
938 "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
939 .to_string(),
940 );
941 }
942
943 Ok(())
944}
945
946pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
950 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
952 if !pass.is_empty() {
953 validate_passphrase_strength(&pass)?;
954 return Ok(Zeroizing::new(pass));
955 }
956 }
957
958 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
960 if let Ok(pass) = std::fs::read_to_string(&path) {
961 let pass = pass
962 .trim_end_matches('\n')
963 .trim_end_matches('\r')
964 .to_string();
965 if !pass.is_empty() {
966 validate_passphrase_strength(&pass)?;
967 return Ok(Zeroizing::new(pass));
968 }
969 }
970 }
971
972 if !std::io::stdin().is_terminal() {
974 return Err(
975 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
976 LIT_PASSPHRASE_FILE"
977 .to_string(),
978 );
979 }
980
981 let pass1 = rpassword::prompt_password(prompt_text)
983 .map_err(|e| format!("Failed to read passphrase: {}", e))?;
984
985 let pass2 = rpassword::prompt_password("Confirm passphrase: ")
986 .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
987
988 if pass1 != pass2 {
989 return Err("Passphrases do not match".to_string());
990 }
991
992 validate_passphrase_strength(&pass1)?;
993
994 Ok(Zeroizing::new(pass1))
995}
996
997pub struct EncryptionManager {
999 config: EncryptionConfig,
1000 engine: Option<EncryptionEngine>,
1001 repo_path: Option<String>,
1002}
1003
1004impl EncryptionManager {
1005 pub fn new(config: EncryptionConfig) -> Self {
1007 EncryptionManager {
1008 config,
1009 engine: None,
1010 repo_path: None,
1011 }
1012 }
1013
1014 pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
1027 let mut manager = EncryptionManager::new(config);
1028 if !manager.config.enabled {
1029 return manager;
1030 }
1031
1032 let repo = repo_path.to_string_lossy().to_string();
1033 let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
1034 return manager;
1035 };
1036
1037 manager.repo_path = Some(repo.clone());
1038 if let Err(e) = manager.initialize(&passphrase) {
1039 eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
1040 return manager;
1041 }
1042
1043 if manager.config.cache_timeout_secs > 0 {
1044 let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
1045 cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
1046 }
1047
1048 manager
1049 }
1050
1051 pub fn is_encrypted_payload(data: &[u8]) -> bool {
1058 data.first() == Some(&ENCRYPTION_VERSION)
1059 }
1060
1061 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
1063 if !self.config.enabled {
1064 return Ok(());
1065 }
1066
1067 let expanded = shellexpand::tilde(&self.config.key_file);
1068 let key_file = Path::new(expanded.as_ref());
1069
1070 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
1081 if let Some(key) = cached_derived_key(&cache_id) {
1082 self.engine = Some(EncryptionEngine::new(&key)?);
1083 return Ok(());
1084 }
1085
1086 let key = if key_file.exists() {
1088 EncryptionKey::load(key_file, passphrase)?
1089 } else {
1090 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1091 key.save(&self.config.key_file, passphrase)?;
1092 key
1093 };
1094
1095 let key = std::sync::Arc::new(key);
1096 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1097
1098 self.engine = Some(EncryptionEngine::new(&key)?);
1100
1101 Ok(())
1102 }
1103
1104 pub fn initialize_with_cache(
1106 &mut self,
1107 repo_path: &str,
1108 passphrase: Option<&str>,
1109 ) -> Result<(), String> {
1110 if !self.config.enabled {
1111 return Ok(());
1112 }
1113
1114 self.repo_path = Some(repo_path.to_string());
1115
1116 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1118 Zeroizing::new(pass.to_string())
1119 } else if let Some(cached) = get_cached_passphrase(repo_path) {
1120 cached
1121 } else {
1122 return Err("No passphrase provided and no valid cached passphrase found".to_string());
1123 };
1124
1125 self.initialize(&actual_passphrase)?;
1127
1128 if self.config.cache_timeout_secs > 0 {
1130 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1131 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1132 }
1133
1134 Ok(())
1135 }
1136
1137 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1139 if !self.config.enabled {
1140 return Ok(plaintext.to_vec());
1141 }
1142
1143 match &self.engine {
1144 Some(engine) => engine.encrypt(plaintext),
1145 None => Err(
1146 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1147 ),
1148 }
1149 }
1150
1151 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1153 if !self.config.enabled {
1154 return Ok(encrypted.to_vec());
1155 }
1156
1157 match &self.engine {
1158 Some(engine) => {
1159 if encrypted
1166 .first()
1167 .is_some_and(|version| *version != ENCRYPTION_VERSION)
1168 {
1169 return Err(
1170 "This data has no Lit encryption header. Encryption cannot be \
1171 enabled for a repository that already contains unencrypted \
1172 commits — start a new encrypted repository and import into it."
1173 .to_string(),
1174 );
1175 }
1176 engine.decrypt(encrypted)
1177 }
1178 None => Err(
1179 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1180 ),
1181 }
1182 }
1183
1184 pub fn is_enabled(&self) -> bool {
1186 self.config.enabled
1187 }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1202
1203 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1204 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1205 }
1206
1207 fn test_key_path(label: &str) -> std::path::PathBuf {
1216 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1217 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1218 let path = std::env::temp_dir().join(format!(
1219 "lit_enc_test_{}_{}_{}.key",
1220 std::process::id(),
1221 label,
1222 n
1223 ));
1224 let _ = fs::remove_file(&path);
1225 path
1226 }
1227
1228 #[test]
1229 fn test_key_derivation() {
1230 let passphrase = "test-passphrase-12345";
1231 let salt = EncryptionKey::generate_salt();
1232
1233 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1234 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1235
1236 assert_eq!(key1.as_bytes(), key2.as_bytes());
1238 }
1239
1240 #[test]
1241 fn test_encryption_decryption() {
1242 let passphrase = "test-secure-passphrase";
1243 let salt = EncryptionKey::generate_salt();
1244 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1245
1246 let engine = EncryptionEngine::new(&key).unwrap();
1247
1248 let plaintext = b"Hello, this is secret data!";
1249
1250 let encrypted = engine.encrypt(plaintext).unwrap();
1252
1253 assert_ne!(encrypted.as_slice(), plaintext);
1255
1256 let decrypted = engine.decrypt(&encrypted).unwrap();
1258
1259 assert_eq!(decrypted.as_slice(), plaintext);
1261 }
1262
1263 #[test]
1264 fn test_encryption_nonce_randomness() {
1265 let passphrase = "test-passphrase";
1266 let salt = EncryptionKey::generate_salt();
1267 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1268
1269 let engine = EncryptionEngine::new(&key).unwrap();
1270
1271 let plaintext = b"Same data";
1272
1273 let encrypted1 = engine.encrypt(plaintext).unwrap();
1275 let encrypted2 = engine.encrypt(plaintext).unwrap();
1276
1277 assert_ne!(encrypted1, encrypted2);
1279
1280 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1282 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1283 }
1284
1285 #[test]
1286 fn test_tampering_detection() {
1287 let passphrase = "test-passphrase";
1288 let salt = EncryptionKey::generate_salt();
1289 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1290
1291 let engine = EncryptionEngine::new(&key).unwrap();
1292
1293 let plaintext = b"Secret data";
1294 let mut encrypted = engine.encrypt(plaintext).unwrap();
1295
1296 let len = encrypted.len();
1298 encrypted[len - 1] ^= 0x01;
1299
1300 assert!(engine.decrypt(&encrypted).is_err());
1302 }
1303
1304 #[test]
1305 fn test_encryption_manager_disabled() {
1306 let config = EncryptionConfig {
1307 enabled: false,
1308 ..Default::default()
1309 };
1310
1311 let manager = EncryptionManager::new(config);
1312
1313 let data = b"Some data";
1314
1315 assert_eq!(manager.encrypt(data).unwrap(), data);
1317 assert_eq!(manager.decrypt(data).unwrap(), data);
1318 }
1319
1320 #[test]
1321 fn test_passphrase_caching() {
1322 let _guard = cache_test_guard();
1323 let repo_path = "/tmp/test-repo";
1324 let passphrase = "cache-test-passphrase".to_string();
1325
1326 clear_passphrase_cache();
1328
1329 assert!(get_cached_passphrase(repo_path).is_none());
1331
1332 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1334
1335 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1337
1338 clear_cached_passphrase(repo_path);
1340 assert!(get_cached_passphrase(repo_path).is_none());
1341 }
1342
1343 #[test]
1344 fn test_passphrase_cache_expiration() {
1345 let _guard = cache_test_guard();
1346 let repo_path = "/tmp/test-repo-expire";
1347 let passphrase = "expire-test".to_string();
1348
1349 clear_passphrase_cache();
1350
1351 cache_passphrase(
1358 repo_path,
1359 passphrase.clone(),
1360 Some(Duration::from_millis(200)),
1361 );
1362
1363 std::thread::sleep(Duration::from_millis(600));
1364
1365 assert!(get_cached_passphrase(repo_path).is_none());
1367 }
1368
1369 #[test]
1370 fn test_passphrase_cache_multiple_repos() {
1371 let _guard = cache_test_guard();
1372 let repo1 = "/tmp/multi-cache-repo1";
1373 let repo2 = "/tmp/multi-cache-repo2";
1374 let pass1 = "password1".to_string();
1375 let pass2 = "password2".to_string();
1376
1377 clear_passphrase_cache();
1378
1379 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1381 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1382
1383 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1385 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1386 }
1387
1388 #[test]
1389 fn test_encryption_manager_with_cache() {
1390 use std::env;
1391
1392 let _guard = cache_test_guard();
1393
1394 let key_file = test_key_path("manager_cache");
1395
1396 let temp_dir = env::temp_dir();
1397 let repo_path = temp_dir.join("test-cache-manager");
1398 let repo_str = repo_path.to_str().unwrap();
1399
1400 clear_passphrase_cache();
1401
1402 let config = EncryptionConfig {
1403 enabled: true,
1404 key_file: key_file.to_string_lossy().into_owned(),
1405 cache_timeout_secs: 300, ..Default::default()
1407 };
1408
1409 let mut manager = EncryptionManager::new(config);
1410 let passphrase = "test-cache-manager-pass";
1411
1412 manager
1414 .initialize_with_cache(repo_str, Some(passphrase))
1415 .unwrap();
1416
1417 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1419
1420 let mut manager2 = EncryptionManager::new(manager.config.clone());
1422 manager2.initialize_with_cache(repo_str, None).unwrap();
1423
1424 clear_passphrase_cache();
1426 let _ = fs::remove_file(&key_file);
1427 }
1428
1429 #[test]
1438 fn test_throttle_state_outlives_the_process() {
1439 let dir = tempfile::tempdir().unwrap();
1440 let key_path = dir.path().join("outlives.key");
1441 let key = key_path.to_string_lossy().into_owned();
1442
1443 for _ in 0..5 {
1444 record_failed_attempt(&key);
1445 }
1446
1447 let seen = load_throttle(&key);
1449 assert_eq!(seen.count, 5, "the count should have survived on disk");
1450 assert!(
1451 seen.lockout_until.is_some(),
1452 "five failures should have produced a lockout a new process can see"
1453 );
1454
1455 assert!(
1457 check_rate_limit(&key).is_err(),
1458 "a locked-out key should be refused"
1459 );
1460
1461 clear_failed_attempts(&key);
1463 assert_eq!(load_throttle(&key).count, 0);
1464 assert!(check_rate_limit(&key).is_ok());
1465 }
1466
1467 #[test]
1470 fn test_unreadable_throttle_state_is_treated_as_a_clean_slate() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let key_path = dir.path().join("corrupt.key");
1473 let key = key_path.to_string_lossy().into_owned();
1474
1475 fs::write(throttle_state_path(&key), b"this is not json").unwrap();
1476
1477 assert_eq!(load_throttle(&key).count, 0);
1478 assert!(check_rate_limit(&key).is_ok());
1479 }
1480
1481 #[test]
1487 #[ignore]
1488 fn test_rate_limiting() {
1489 let key_file = test_key_path("rate_limiting");
1490 let key_file_str = key_file.to_string_lossy().into_owned();
1491
1492 let passphrase = "correct-passphrase-1234567890";
1495 let salt = EncryptionKey::generate_salt();
1496 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1497 key.save(&key_file_str, passphrase).unwrap();
1498
1499 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1501
1502 let start = std::time::Instant::now();
1509 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1510 .err()
1511 .expect("an attempt inside the backoff window must be refused");
1512 assert!(
1513 throttled.contains("wait"),
1514 "expected a rate-limit refusal, got: {}",
1515 throttled
1516 );
1517 assert!(
1518 start.elapsed() < Duration::from_secs(1),
1519 "the throttle should refuse immediately rather than block the caller"
1520 );
1521
1522 std::thread::sleep(Duration::from_millis(2_100));
1525 let correct = EncryptionKey::load(&key_file, passphrase);
1526 assert!(
1527 correct.is_ok(),
1528 "the correct passphrase should be accepted once the window passes: {:?}",
1529 correct.as_ref().err()
1530 );
1531
1532 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1535 .err()
1536 .expect("a wrong passphrase must still fail");
1537 assert!(
1538 !after_reset.contains("wait"),
1539 "a successful load should reset the counter, got: {}",
1540 after_reset
1541 );
1542
1543 let _ = fs::remove_file(&key_file);
1544 }
1545
1546 #[test]
1555 fn test_nonces_do_not_repeat_across_engines() {
1556 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1557
1558 let mut nonces = std::collections::HashSet::new();
1559 let mut leading_zero_runs = 0;
1560
1561 for _ in 0..64 {
1562 let engine = EncryptionEngine::new(&key).unwrap();
1564 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1565 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1566
1567 if nonce[..8] == [0u8; 8] {
1568 leading_zero_runs += 1;
1569 }
1570 assert!(
1571 nonces.insert(nonce),
1572 "a nonce repeated across engines, which breaks AES-GCM"
1573 );
1574 }
1575
1576 assert!(
1578 leading_zero_runs <= 1,
1579 "{} of 64 nonces began with eight zero bytes, which means the \
1580 counter is resetting rather than the nonce being random",
1581 leading_zero_runs
1582 );
1583 }
1584}
1585
1586#[cfg(test)]
1587mod key_file_permission_tests {
1588 use super::*;
1589
1590 #[test]
1597 fn test_key_file_can_be_saved_over() {
1598 let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1599 let _ = fs::remove_file(&path);
1600 let path_str = path.to_string_lossy().to_string();
1601
1602 let first =
1603 EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1604 first
1605 .save(&path_str, "FirstPassphrase!123")
1606 .expect("first save should succeed");
1607
1608 let second =
1609 EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1610 second
1611 .save(&path_str, "SecondPassphrase!234")
1612 .expect("saving over an existing key file should succeed, as rotate-key does");
1613
1614 let stored = fs::read(&path).unwrap();
1616 assert_eq!(
1617 &stored[..SALT_SIZE],
1618 &[2u8; SALT_SIZE],
1619 "the rewrite should have taken effect"
1620 );
1621
1622 let _ = fs::remove_file(&path);
1623 }
1624
1625 #[test]
1630 fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1631 let dir = tempfile::tempdir().unwrap();
1632 let path = dir.path().join("preexisting.key");
1633 fs::write(&path, b"secret").unwrap();
1634
1635 #[cfg(unix)]
1636 {
1637 use std::os::unix::fs::PermissionsExt;
1638 let mut perms = fs::metadata(&path).unwrap().permissions();
1639 perms.set_mode(0o644);
1640 fs::set_permissions(&path, perms).unwrap();
1641
1642 restrict_to_owner(&path).unwrap();
1643
1644 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1645 assert_eq!(mode, 0o600, "group and other should have lost all access");
1646 }
1647
1648 #[cfg(windows)]
1649 restrict_to_owner(&path).unwrap();
1650
1651 assert_eq!(fs::read(&path).unwrap(), b"secret");
1654 }
1655}