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 None
861}
862
863pub fn prompt_for_passphrase(
869 repo_path: &str,
870 config: &EncryptionConfig,
871 prompt_text: &str,
872) -> Result<Zeroizing<String>, String> {
873 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
875 return Ok(pass);
876 }
877
878 if !std::io::stdin().is_terminal() {
881 return Err(
882 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
883 LIT_PASSPHRASE_FILE"
884 .to_string(),
885 );
886 }
887
888 rpassword::prompt_password(prompt_text)
890 .map(Zeroizing::new)
891 .map_err(|e| format!("Failed to read passphrase: {}", e))
892}
893
894const MIN_PASSPHRASE_LENGTH: usize = 16;
896
897fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
903 #[cfg(test)]
905 if passphrase.starts_with("test-") {
906 return Ok(());
907 }
908
909 if passphrase.len() < MIN_PASSPHRASE_LENGTH {
910 return Err(format!(
911 "Passphrase must be at least {} characters (recommended: 20+)",
912 MIN_PASSPHRASE_LENGTH
913 ));
914 }
915
916 let has_upper = passphrase.chars().any(|c| c.is_uppercase());
918 let has_lower = passphrase.chars().any(|c| c.is_lowercase());
919 let has_digit = passphrase.chars().any(|c| c.is_numeric());
920 let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
921
922 let complexity_count = [has_upper, has_lower, has_digit, has_special]
923 .iter()
924 .filter(|&&x| x)
925 .count();
926
927 if complexity_count < 3 {
928 return Err(
929 "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
930 .to_string(),
931 );
932 }
933
934 Ok(())
935}
936
937pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
941 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
943 if !pass.is_empty() {
944 validate_passphrase_strength(&pass)?;
945 return Ok(Zeroizing::new(pass));
946 }
947 }
948
949 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
951 if let Ok(pass) = std::fs::read_to_string(&path) {
952 let pass = pass
953 .trim_end_matches('\n')
954 .trim_end_matches('\r')
955 .to_string();
956 if !pass.is_empty() {
957 validate_passphrase_strength(&pass)?;
958 return Ok(Zeroizing::new(pass));
959 }
960 }
961 }
962
963 if !std::io::stdin().is_terminal() {
965 return Err(
966 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
967 LIT_PASSPHRASE_FILE"
968 .to_string(),
969 );
970 }
971
972 let pass1 = rpassword::prompt_password(prompt_text)
974 .map_err(|e| format!("Failed to read passphrase: {}", e))?;
975
976 let pass2 = rpassword::prompt_password("Confirm passphrase: ")
977 .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
978
979 if pass1 != pass2 {
980 return Err("Passphrases do not match".to_string());
981 }
982
983 validate_passphrase_strength(&pass1)?;
984
985 Ok(Zeroizing::new(pass1))
986}
987
988pub struct EncryptionManager {
990 config: EncryptionConfig,
991 engine: Option<EncryptionEngine>,
992 repo_path: Option<String>,
993}
994
995impl EncryptionManager {
996 pub fn new(config: EncryptionConfig) -> Self {
998 EncryptionManager {
999 config,
1000 engine: None,
1001 repo_path: None,
1002 }
1003 }
1004
1005 pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
1018 let mut manager = EncryptionManager::new(config);
1019 if !manager.config.enabled {
1020 return manager;
1021 }
1022
1023 let repo = repo_path.to_string_lossy().to_string();
1024 let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
1025 return manager;
1026 };
1027
1028 manager.repo_path = Some(repo.clone());
1029 if let Err(e) = manager.initialize(&passphrase) {
1030 eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
1031 return manager;
1032 }
1033
1034 if manager.config.cache_timeout_secs > 0 {
1035 let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
1036 cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
1037 }
1038
1039 manager
1040 }
1041
1042 pub fn is_encrypted_payload(data: &[u8]) -> bool {
1049 data.first() == Some(&ENCRYPTION_VERSION)
1050 }
1051
1052 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
1054 if !self.config.enabled {
1055 return Ok(());
1056 }
1057
1058 let expanded = shellexpand::tilde(&self.config.key_file);
1059 let key_file = Path::new(expanded.as_ref());
1060
1061 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
1072 if let Some(key) = cached_derived_key(&cache_id) {
1073 self.engine = Some(EncryptionEngine::new(&key)?);
1074 return Ok(());
1075 }
1076
1077 let key = if key_file.exists() {
1079 EncryptionKey::load(key_file, passphrase)?
1080 } else {
1081 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1082 key.save(&self.config.key_file, passphrase)?;
1083 key
1084 };
1085
1086 let key = std::sync::Arc::new(key);
1087 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1088
1089 self.engine = Some(EncryptionEngine::new(&key)?);
1091
1092 Ok(())
1093 }
1094
1095 pub fn initialize_with_cache(
1097 &mut self,
1098 repo_path: &str,
1099 passphrase: Option<&str>,
1100 ) -> Result<(), String> {
1101 if !self.config.enabled {
1102 return Ok(());
1103 }
1104
1105 self.repo_path = Some(repo_path.to_string());
1106
1107 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1109 Zeroizing::new(pass.to_string())
1110 } else if let Some(cached) = get_cached_passphrase(repo_path) {
1111 cached
1112 } else {
1113 return Err("No passphrase provided and no valid cached passphrase found".to_string());
1114 };
1115
1116 self.initialize(&actual_passphrase)?;
1118
1119 if self.config.cache_timeout_secs > 0 {
1121 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1122 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1123 }
1124
1125 Ok(())
1126 }
1127
1128 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1130 if !self.config.enabled {
1131 return Ok(plaintext.to_vec());
1132 }
1133
1134 match &self.engine {
1135 Some(engine) => engine.encrypt(plaintext),
1136 None => Err(
1137 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1138 ),
1139 }
1140 }
1141
1142 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1144 if !self.config.enabled {
1145 return Ok(encrypted.to_vec());
1146 }
1147
1148 match &self.engine {
1149 Some(engine) => {
1150 if encrypted
1157 .first()
1158 .is_some_and(|version| *version != ENCRYPTION_VERSION)
1159 {
1160 return Err(
1161 "This data has no Lit encryption header. Encryption cannot be \
1162 enabled for a repository that already contains unencrypted \
1163 commits — start a new encrypted repository and import into it."
1164 .to_string(),
1165 );
1166 }
1167 engine.decrypt(encrypted)
1168 }
1169 None => Err(
1170 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1171 ),
1172 }
1173 }
1174
1175 pub fn is_enabled(&self) -> bool {
1177 self.config.enabled
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184
1185 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1193
1194 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1195 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1196 }
1197
1198 fn test_key_path(label: &str) -> std::path::PathBuf {
1207 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1208 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1209 let path = std::env::temp_dir().join(format!(
1210 "lit_enc_test_{}_{}_{}.key",
1211 std::process::id(),
1212 label,
1213 n
1214 ));
1215 let _ = fs::remove_file(&path);
1216 path
1217 }
1218
1219 #[test]
1220 fn test_key_derivation() {
1221 let passphrase = "test-passphrase-12345";
1222 let salt = EncryptionKey::generate_salt();
1223
1224 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1225 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1226
1227 assert_eq!(key1.as_bytes(), key2.as_bytes());
1229 }
1230
1231 #[test]
1232 fn test_encryption_decryption() {
1233 let passphrase = "test-secure-passphrase";
1234 let salt = EncryptionKey::generate_salt();
1235 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1236
1237 let engine = EncryptionEngine::new(&key).unwrap();
1238
1239 let plaintext = b"Hello, this is secret data!";
1240
1241 let encrypted = engine.encrypt(plaintext).unwrap();
1243
1244 assert_ne!(encrypted.as_slice(), plaintext);
1246
1247 let decrypted = engine.decrypt(&encrypted).unwrap();
1249
1250 assert_eq!(decrypted.as_slice(), plaintext);
1252 }
1253
1254 #[test]
1255 fn test_encryption_nonce_randomness() {
1256 let passphrase = "test-passphrase";
1257 let salt = EncryptionKey::generate_salt();
1258 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1259
1260 let engine = EncryptionEngine::new(&key).unwrap();
1261
1262 let plaintext = b"Same data";
1263
1264 let encrypted1 = engine.encrypt(plaintext).unwrap();
1266 let encrypted2 = engine.encrypt(plaintext).unwrap();
1267
1268 assert_ne!(encrypted1, encrypted2);
1270
1271 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1273 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1274 }
1275
1276 #[test]
1277 fn test_tampering_detection() {
1278 let passphrase = "test-passphrase";
1279 let salt = EncryptionKey::generate_salt();
1280 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1281
1282 let engine = EncryptionEngine::new(&key).unwrap();
1283
1284 let plaintext = b"Secret data";
1285 let mut encrypted = engine.encrypt(plaintext).unwrap();
1286
1287 let len = encrypted.len();
1289 encrypted[len - 1] ^= 0x01;
1290
1291 assert!(engine.decrypt(&encrypted).is_err());
1293 }
1294
1295 #[test]
1296 fn test_encryption_manager_disabled() {
1297 let config = EncryptionConfig {
1298 enabled: false,
1299 ..Default::default()
1300 };
1301
1302 let manager = EncryptionManager::new(config);
1303
1304 let data = b"Some data";
1305
1306 assert_eq!(manager.encrypt(data).unwrap(), data);
1308 assert_eq!(manager.decrypt(data).unwrap(), data);
1309 }
1310
1311 #[test]
1312 fn test_passphrase_caching() {
1313 let _guard = cache_test_guard();
1314 let repo_path = "/tmp/test-repo";
1315 let passphrase = "cache-test-passphrase".to_string();
1316
1317 clear_passphrase_cache();
1319
1320 assert!(get_cached_passphrase(repo_path).is_none());
1322
1323 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1325
1326 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1328
1329 clear_cached_passphrase(repo_path);
1331 assert!(get_cached_passphrase(repo_path).is_none());
1332 }
1333
1334 #[test]
1335 fn test_passphrase_cache_expiration() {
1336 let _guard = cache_test_guard();
1337 let repo_path = "/tmp/test-repo-expire";
1338 let passphrase = "expire-test".to_string();
1339
1340 clear_passphrase_cache();
1341
1342 cache_passphrase(
1349 repo_path,
1350 passphrase.clone(),
1351 Some(Duration::from_millis(200)),
1352 );
1353
1354 std::thread::sleep(Duration::from_millis(600));
1355
1356 assert!(get_cached_passphrase(repo_path).is_none());
1358 }
1359
1360 #[test]
1361 fn test_passphrase_cache_multiple_repos() {
1362 let _guard = cache_test_guard();
1363 let repo1 = "/tmp/multi-cache-repo1";
1364 let repo2 = "/tmp/multi-cache-repo2";
1365 let pass1 = "password1".to_string();
1366 let pass2 = "password2".to_string();
1367
1368 clear_passphrase_cache();
1369
1370 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1372 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1373
1374 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1376 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1377 }
1378
1379 #[test]
1380 fn test_encryption_manager_with_cache() {
1381 use std::env;
1382
1383 let _guard = cache_test_guard();
1384
1385 let key_file = test_key_path("manager_cache");
1386
1387 let temp_dir = env::temp_dir();
1388 let repo_path = temp_dir.join("test-cache-manager");
1389 let repo_str = repo_path.to_str().unwrap();
1390
1391 clear_passphrase_cache();
1392
1393 let config = EncryptionConfig {
1394 enabled: true,
1395 key_file: key_file.to_string_lossy().into_owned(),
1396 cache_timeout_secs: 300, ..Default::default()
1398 };
1399
1400 let mut manager = EncryptionManager::new(config);
1401 let passphrase = "test-cache-manager-pass";
1402
1403 manager
1405 .initialize_with_cache(repo_str, Some(passphrase))
1406 .unwrap();
1407
1408 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1410
1411 let mut manager2 = EncryptionManager::new(manager.config.clone());
1413 manager2.initialize_with_cache(repo_str, None).unwrap();
1414
1415 clear_passphrase_cache();
1417 let _ = fs::remove_file(&key_file);
1418 }
1419
1420 #[test]
1429 fn test_throttle_state_outlives_the_process() {
1430 let dir = tempfile::tempdir().unwrap();
1431 let key_path = dir.path().join("outlives.key");
1432 let key = key_path.to_string_lossy().into_owned();
1433
1434 for _ in 0..5 {
1435 record_failed_attempt(&key);
1436 }
1437
1438 let seen = load_throttle(&key);
1440 assert_eq!(seen.count, 5, "the count should have survived on disk");
1441 assert!(
1442 seen.lockout_until.is_some(),
1443 "five failures should have produced a lockout a new process can see"
1444 );
1445
1446 assert!(
1448 check_rate_limit(&key).is_err(),
1449 "a locked-out key should be refused"
1450 );
1451
1452 clear_failed_attempts(&key);
1454 assert_eq!(load_throttle(&key).count, 0);
1455 assert!(check_rate_limit(&key).is_ok());
1456 }
1457
1458 #[test]
1461 fn test_unreadable_throttle_state_is_treated_as_a_clean_slate() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let key_path = dir.path().join("corrupt.key");
1464 let key = key_path.to_string_lossy().into_owned();
1465
1466 fs::write(throttle_state_path(&key), b"this is not json").unwrap();
1467
1468 assert_eq!(load_throttle(&key).count, 0);
1469 assert!(check_rate_limit(&key).is_ok());
1470 }
1471
1472 #[test]
1478 #[ignore]
1479 fn test_rate_limiting() {
1480 let key_file = test_key_path("rate_limiting");
1481 let key_file_str = key_file.to_string_lossy().into_owned();
1482
1483 let passphrase = "correct-passphrase-1234567890";
1486 let salt = EncryptionKey::generate_salt();
1487 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1488 key.save(&key_file_str, passphrase).unwrap();
1489
1490 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1492
1493 let start = std::time::Instant::now();
1500 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1501 .err()
1502 .expect("an attempt inside the backoff window must be refused");
1503 assert!(
1504 throttled.contains("wait"),
1505 "expected a rate-limit refusal, got: {}",
1506 throttled
1507 );
1508 assert!(
1509 start.elapsed() < Duration::from_secs(1),
1510 "the throttle should refuse immediately rather than block the caller"
1511 );
1512
1513 std::thread::sleep(Duration::from_millis(2_100));
1516 let correct = EncryptionKey::load(&key_file, passphrase);
1517 assert!(
1518 correct.is_ok(),
1519 "the correct passphrase should be accepted once the window passes: {:?}",
1520 correct.as_ref().err()
1521 );
1522
1523 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1526 .err()
1527 .expect("a wrong passphrase must still fail");
1528 assert!(
1529 !after_reset.contains("wait"),
1530 "a successful load should reset the counter, got: {}",
1531 after_reset
1532 );
1533
1534 let _ = fs::remove_file(&key_file);
1535 }
1536
1537 #[test]
1546 fn test_nonces_do_not_repeat_across_engines() {
1547 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1548
1549 let mut nonces = std::collections::HashSet::new();
1550 let mut leading_zero_runs = 0;
1551
1552 for _ in 0..64 {
1553 let engine = EncryptionEngine::new(&key).unwrap();
1555 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1556 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1557
1558 if nonce[..8] == [0u8; 8] {
1559 leading_zero_runs += 1;
1560 }
1561 assert!(
1562 nonces.insert(nonce),
1563 "a nonce repeated across engines, which breaks AES-GCM"
1564 );
1565 }
1566
1567 assert!(
1569 leading_zero_runs <= 1,
1570 "{} of 64 nonces began with eight zero bytes, which means the \
1571 counter is resetting rather than the nonce being random",
1572 leading_zero_runs
1573 );
1574 }
1575}
1576
1577#[cfg(test)]
1578mod key_file_permission_tests {
1579 use super::*;
1580
1581 #[test]
1588 fn test_key_file_can_be_saved_over() {
1589 let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1590 let _ = fs::remove_file(&path);
1591 let path_str = path.to_string_lossy().to_string();
1592
1593 let first =
1594 EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1595 first
1596 .save(&path_str, "FirstPassphrase!123")
1597 .expect("first save should succeed");
1598
1599 let second =
1600 EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1601 second
1602 .save(&path_str, "SecondPassphrase!234")
1603 .expect("saving over an existing key file should succeed, as rotate-key does");
1604
1605 let stored = fs::read(&path).unwrap();
1607 assert_eq!(
1608 &stored[..SALT_SIZE],
1609 &[2u8; SALT_SIZE],
1610 "the rewrite should have taken effect"
1611 );
1612
1613 let _ = fs::remove_file(&path);
1614 }
1615
1616 #[test]
1621 fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1622 let dir = tempfile::tempdir().unwrap();
1623 let path = dir.path().join("preexisting.key");
1624 fs::write(&path, b"secret").unwrap();
1625
1626 #[cfg(unix)]
1627 {
1628 use std::os::unix::fs::PermissionsExt;
1629 let mut perms = fs::metadata(&path).unwrap().permissions();
1630 perms.set_mode(0o644);
1631 fs::set_permissions(&path, perms).unwrap();
1632
1633 restrict_to_owner(&path).unwrap();
1634
1635 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1636 assert_eq!(mode, 0o600, "group and other should have lost all access");
1637 }
1638
1639 #[cfg(windows)]
1640 restrict_to_owner(&path).unwrap();
1641
1642 assert_eq!(fs::read(&path).unwrap(), b"secret");
1645 }
1646}