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 check_rate_limit(repo_path: &str) -> Result<(), String> {
310 let mut attempts = FAILED_ATTEMPTS
311 .lock()
312 .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
313 let tracker = attempts
314 .entry(repo_path.to_string())
315 .or_insert_with(|| FailedAttemptTracker {
316 count: 0,
317 last_attempt: SystemTime::now(),
318 lockout_until: None,
319 });
320
321 if let Some(lockout) = tracker.lockout_until {
323 if SystemTime::now() < lockout {
324 let remaining = lockout
325 .duration_since(SystemTime::now())
326 .unwrap_or(Duration::from_secs(0));
327 return Err(format!(
328 "Too many failed attempts. Please wait {} seconds before trying again.",
329 remaining.as_secs()
330 ));
331 }
332 tracker.lockout_until = None;
334 tracker.count = 0;
335 }
336
337 if tracker.count > 0 {
339 let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
340 if let Ok(elapsed) = tracker.last_attempt.elapsed() {
341 if elapsed < delay {
342 let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
343 return Err(format!(
344 "Please wait {} seconds between passphrase attempts.",
345 remaining
346 ));
347 }
348 }
349 }
350
351 Ok(())
352}
353
354fn record_failed_attempt(repo_path: &str) {
356 let Ok(mut attempts) = FAILED_ATTEMPTS.lock() else {
357 return;
358 };
359 let tracker = attempts
360 .entry(repo_path.to_string())
361 .or_insert_with(|| FailedAttemptTracker {
362 count: 0,
363 last_attempt: SystemTime::now(),
364 lockout_until: None,
365 });
366
367 tracker.count += 1;
368 tracker.last_attempt = SystemTime::now();
369
370 if tracker.count >= 5 {
372 tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
373 eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
374 }
375}
376
377fn clear_failed_attempts(repo_path: &str) {
379 if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
380 attempts.remove(repo_path);
381 }
382}
383
384#[derive(ZeroizeOnDrop)]
386#[allow(unused_assignments)]
387pub struct EncryptionKey {
388 key_bytes: [u8; KEY_SIZE],
389 #[zeroize(skip)]
391 salt: [u8; SALT_SIZE],
392}
393
394impl EncryptionKey {
395 pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
397 #[cfg(not(test))]
399 validate_passphrase_strength(passphrase)?;
400 #[cfg(test)]
401 if !passphrase.starts_with("test-") {
402 validate_passphrase_strength(passphrase)?;
403 }
404
405 if salt.len() != SALT_SIZE {
406 return Err(format!(
407 "Invalid salt size: expected {}, got {}",
408 SALT_SIZE,
409 salt.len()
410 ));
411 }
412
413 let mut key_bytes = [0u8; KEY_SIZE];
414 pbkdf2_hmac::<Sha512>(
415 passphrase.as_bytes(),
416 salt,
417 PBKDF2_ITERATIONS,
418 &mut key_bytes,
419 );
420
421 let mut salt_array = [0u8; SALT_SIZE];
422 salt_array.copy_from_slice(salt);
423
424 Ok(EncryptionKey {
425 key_bytes,
426 salt: salt_array,
427 })
428 }
429
430 pub fn generate_salt() -> [u8; SALT_SIZE] {
432 use aes_gcm::aead::rand_core::RngCore;
433 let mut salt = [0u8; SALT_SIZE];
434 OsRng.fill_bytes(&mut salt);
435 salt
436 }
437
438 pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
442 let key_file_str = key_file.to_string_lossy().to_string();
444 #[cfg(not(test))]
445 check_rate_limit(&key_file_str)?;
446 #[cfg(test)]
447 if !passphrase.starts_with("test-") {
448 check_rate_limit(&key_file_str)?;
449 }
450
451 if !key_file.exists() {
452 return Err(
453 "Encryption key file not found. Initialize repository with encryption first."
454 .to_string(),
455 );
456 }
457
458 restrict_to_owner(key_file)?;
463
464 let encrypted_data =
465 fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
466
467 if encrypted_data.len() < SALT_SIZE + 1 {
468 return Err("Invalid key file format (too short)".to_string());
469 }
470
471 let salt = &encrypted_data[0..SALT_SIZE];
473 let version = encrypted_data[SALT_SIZE];
474
475 if version != ENCRYPTION_VERSION {
476 return Err(format!("Unsupported key file version: {}", version));
477 }
478
479 if encrypted_data.len() == SALT_SIZE + 1 {
481 let key = Self::from_passphrase(passphrase, salt)?;
483 clear_failed_attempts(&key_file_str);
485 return Ok(key);
486 }
487
488 if encrypted_data.len() < SALT_SIZE + 1 + 32 {
489 return Err("Invalid key file format (unexpected size)".to_string());
490 }
491
492 let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
493
494 let key = Self::from_passphrase(passphrase, salt)?;
496
497 use sha2::{Digest, Sha256};
499 let mut hasher = Sha256::new();
500 hasher.update(b"lit-passphrase-verification-v1");
501 hasher.update(&key.key_bytes);
502 let verification_hash = hasher.finalize();
503
504 use subtle::ConstantTimeEq;
506 if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
507 #[cfg(not(test))]
509 record_failed_attempt(&key_file_str);
510 #[cfg(test)]
511 if !passphrase.starts_with("test-") {
512 record_failed_attempt(&key_file_str);
513 }
514 std::thread::sleep(std::time::Duration::from_millis(100));
516 return Err("Invalid passphrase".to_string());
517 }
518
519 clear_failed_attempts(&key_file_str);
521 Ok(key)
522 }
523
524 pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
528 let expanded = shellexpand::tilde(key_file_str);
529 let key_file = Path::new(expanded.as_ref());
530
531 use sha2::{Digest, Sha256};
533 let mut hasher = Sha256::new();
534 hasher.update(b"lit-passphrase-verification-v1");
535 hasher.update(self.key_bytes);
536 let verification_hash = hasher.finalize();
537
538 if let Some(parent) = key_file.parent() {
540 fs::create_dir_all(parent)
541 .map_err(|e| format!("Failed to create key directory: {}", e))?;
542 }
543
544 let mut data = Vec::new();
546 data.extend_from_slice(&self.salt);
547 data.push(ENCRYPTION_VERSION);
548 data.extend_from_slice(&verification_hash);
549
550 let temp_file = key_file.with_extension("tmp");
552 fs::write(&temp_file, &data)
553 .map_err(|e| format!("Failed to write temp key file: {}", e))?;
554
555 restrict_to_owner(&temp_file)?;
563
564 allow_replacement(key_file)?;
569
570 fs::rename(&temp_file, key_file)
571 .map_err(|e| format!("Failed to rename key file: {}", e))?;
572
573 Ok(())
574 }
575
576 fn as_bytes(&self) -> &[u8; KEY_SIZE] {
578 &self.key_bytes
579 }
580}
581
582const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
585
586pub struct EncryptionEngine {
589 cipher: Aes256Gcm,
590 nonce_counter: AtomicU64,
592}
593
594impl EncryptionEngine {
595 pub fn new(key: &EncryptionKey) -> Result<Self, String> {
597 crate::crypto::fips::ensure_self_tests()?;
601
602 let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
603 .map_err(|e| format!("Failed to create cipher: {}", e))?;
604
605 Ok(EncryptionEngine {
606 cipher,
607 nonce_counter: AtomicU64::new(0),
608 })
609 }
610
611 #[allow(deprecated)]
616 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
617 let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
624 if count >= MAX_ENCRYPTIONS_PER_KEY {
625 return Err(format!(
626 "Encryption limit exceeded ({} operations). Key rotation required for security.",
627 MAX_ENCRYPTIONS_PER_KEY
628 ));
629 }
630
631 use aes_gcm::aead::rand_core::RngCore;
648 let mut nonce_bytes = [0u8; NONCE_SIZE];
649 OsRng.fill_bytes(&mut nonce_bytes);
650 let nonce = Nonce::from_slice(&nonce_bytes);
651
652 let ciphertext = self
654 .cipher
655 .encrypt(nonce, plaintext)
656 .map_err(|e| format!("Encryption failed: {}", e))?;
657
658 let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
660 output.push(ENCRYPTION_VERSION);
661 output.extend_from_slice(&nonce_bytes);
662 output.extend_from_slice(&ciphertext);
663
664 Ok(output)
665 }
666
667 #[allow(deprecated)]
669 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
670 if encrypted.len() < 1 + NONCE_SIZE {
671 return Err("Invalid encrypted data: too short".to_string());
672 }
673
674 let version = encrypted[0];
676 if version != ENCRYPTION_VERSION {
677 return Err(format!("Unsupported encryption version: {}", version));
678 }
679
680 let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
682 let nonce = Nonce::from_slice(nonce_bytes);
683
684 let ciphertext = &encrypted[1 + NONCE_SIZE..];
686
687 let plaintext = self
689 .cipher
690 .decrypt(nonce, ciphertext)
691 .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
692
693 Ok(plaintext)
694 }
695}
696
697impl CachedPassphrase {
699 fn is_valid(&self) -> bool {
701 SystemTime::now() < self.expires_at
702 }
703}
704
705pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
708 let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
709 let expires_at = SystemTime::now() + timeout;
710
711 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
712 cache.insert(
713 repo_path.to_string(),
714 CachedPassphrase {
715 passphrase: Zeroizing::new(passphrase),
716 expires_at,
717 },
718 );
719 }
720}
721
722pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
725 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
726 if let Some(entry) = cache.get(repo_path) {
727 if entry.is_valid() {
728 return Some(entry.passphrase.clone());
729 } else {
730 cache.remove(repo_path);
732 }
733 }
734 }
735 None
736}
737
738pub fn clear_passphrase_cache() {
740 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
741 cache.clear();
742 }
743}
744
745pub fn clear_cached_passphrase(repo_path: &str) {
747 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
748 cache.remove(repo_path);
749 }
750}
751
752fn get_passphrase_non_interactive(
758 repo_path: &str,
759 config: &EncryptionConfig,
760) -> Option<Zeroizing<String>> {
761 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
763 if !pass.is_empty() {
764 return Some(Zeroizing::new(pass));
765 }
766 }
767
768 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
770 if let Ok(pass) = std::fs::read_to_string(&path) {
771 let pass = pass
772 .trim_end_matches('\n')
773 .trim_end_matches('\r')
774 .to_string();
775 if !pass.is_empty() {
776 return Some(Zeroizing::new(pass));
777 }
778 }
779 }
780
781 if config.cache_timeout_secs > 0 {
783 if let Some(cached) = get_cached_passphrase(repo_path) {
784 return Some(cached);
785 }
786 }
787
788 None
789}
790
791pub fn prompt_for_passphrase(
797 repo_path: &str,
798 config: &EncryptionConfig,
799 prompt_text: &str,
800) -> Result<Zeroizing<String>, String> {
801 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
803 return Ok(pass);
804 }
805
806 if !std::io::stdin().is_terminal() {
809 return Err(
810 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
811 LIT_PASSPHRASE_FILE"
812 .to_string(),
813 );
814 }
815
816 rpassword::prompt_password(prompt_text)
818 .map(Zeroizing::new)
819 .map_err(|e| format!("Failed to read passphrase: {}", e))
820}
821
822const MIN_PASSPHRASE_LENGTH: usize = 16;
824
825fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
831 #[cfg(test)]
833 if passphrase.starts_with("test-") {
834 return Ok(());
835 }
836
837 if passphrase.len() < MIN_PASSPHRASE_LENGTH {
838 return Err(format!(
839 "Passphrase must be at least {} characters (recommended: 20+)",
840 MIN_PASSPHRASE_LENGTH
841 ));
842 }
843
844 let has_upper = passphrase.chars().any(|c| c.is_uppercase());
846 let has_lower = passphrase.chars().any(|c| c.is_lowercase());
847 let has_digit = passphrase.chars().any(|c| c.is_numeric());
848 let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
849
850 let complexity_count = [has_upper, has_lower, has_digit, has_special]
851 .iter()
852 .filter(|&&x| x)
853 .count();
854
855 if complexity_count < 3 {
856 return Err(
857 "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
858 .to_string(),
859 );
860 }
861
862 Ok(())
863}
864
865pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
869 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
871 if !pass.is_empty() {
872 validate_passphrase_strength(&pass)?;
873 return Ok(Zeroizing::new(pass));
874 }
875 }
876
877 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
879 if let Ok(pass) = std::fs::read_to_string(&path) {
880 let pass = pass
881 .trim_end_matches('\n')
882 .trim_end_matches('\r')
883 .to_string();
884 if !pass.is_empty() {
885 validate_passphrase_strength(&pass)?;
886 return Ok(Zeroizing::new(pass));
887 }
888 }
889 }
890
891 if !std::io::stdin().is_terminal() {
893 return Err(
894 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
895 LIT_PASSPHRASE_FILE"
896 .to_string(),
897 );
898 }
899
900 let pass1 = rpassword::prompt_password(prompt_text)
902 .map_err(|e| format!("Failed to read passphrase: {}", e))?;
903
904 let pass2 = rpassword::prompt_password("Confirm passphrase: ")
905 .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
906
907 if pass1 != pass2 {
908 return Err("Passphrases do not match".to_string());
909 }
910
911 validate_passphrase_strength(&pass1)?;
912
913 Ok(Zeroizing::new(pass1))
914}
915
916pub struct EncryptionManager {
918 config: EncryptionConfig,
919 engine: Option<EncryptionEngine>,
920 repo_path: Option<String>,
921}
922
923impl EncryptionManager {
924 pub fn new(config: EncryptionConfig) -> Self {
926 EncryptionManager {
927 config,
928 engine: None,
929 repo_path: None,
930 }
931 }
932
933 pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
946 let mut manager = EncryptionManager::new(config);
947 if !manager.config.enabled {
948 return manager;
949 }
950
951 let repo = repo_path.to_string_lossy().to_string();
952 let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
953 return manager;
954 };
955
956 manager.repo_path = Some(repo.clone());
957 if let Err(e) = manager.initialize(&passphrase) {
958 eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
959 return manager;
960 }
961
962 if manager.config.cache_timeout_secs > 0 {
963 let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
964 cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
965 }
966
967 manager
968 }
969
970 pub fn is_encrypted_payload(data: &[u8]) -> bool {
977 data.first() == Some(&ENCRYPTION_VERSION)
978 }
979
980 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
982 if !self.config.enabled {
983 return Ok(());
984 }
985
986 let expanded = shellexpand::tilde(&self.config.key_file);
987 let key_file = Path::new(expanded.as_ref());
988
989 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
1000 if let Some(key) = cached_derived_key(&cache_id) {
1001 self.engine = Some(EncryptionEngine::new(&key)?);
1002 return Ok(());
1003 }
1004
1005 let key = if key_file.exists() {
1007 EncryptionKey::load(key_file, passphrase)?
1008 } else {
1009 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1010 key.save(&self.config.key_file, passphrase)?;
1011 key
1012 };
1013
1014 let key = std::sync::Arc::new(key);
1015 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1016
1017 self.engine = Some(EncryptionEngine::new(&key)?);
1019
1020 Ok(())
1021 }
1022
1023 pub fn initialize_with_cache(
1025 &mut self,
1026 repo_path: &str,
1027 passphrase: Option<&str>,
1028 ) -> Result<(), String> {
1029 if !self.config.enabled {
1030 return Ok(());
1031 }
1032
1033 self.repo_path = Some(repo_path.to_string());
1034
1035 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1037 Zeroizing::new(pass.to_string())
1038 } else if let Some(cached) = get_cached_passphrase(repo_path) {
1039 cached
1040 } else {
1041 return Err("No passphrase provided and no valid cached passphrase found".to_string());
1042 };
1043
1044 self.initialize(&actual_passphrase)?;
1046
1047 if self.config.cache_timeout_secs > 0 {
1049 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1050 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1051 }
1052
1053 Ok(())
1054 }
1055
1056 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1058 if !self.config.enabled {
1059 return Ok(plaintext.to_vec());
1060 }
1061
1062 match &self.engine {
1063 Some(engine) => engine.encrypt(plaintext),
1064 None => Err(
1065 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1066 ),
1067 }
1068 }
1069
1070 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1072 if !self.config.enabled {
1073 return Ok(encrypted.to_vec());
1074 }
1075
1076 match &self.engine {
1077 Some(engine) => {
1078 if encrypted
1085 .first()
1086 .is_some_and(|version| *version != ENCRYPTION_VERSION)
1087 {
1088 return Err(
1089 "This data has no Lit encryption header. Encryption cannot be \
1090 enabled for a repository that already contains unencrypted \
1091 commits — start a new encrypted repository and import into it."
1092 .to_string(),
1093 );
1094 }
1095 engine.decrypt(encrypted)
1096 }
1097 None => Err(
1098 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1099 ),
1100 }
1101 }
1102
1103 pub fn is_enabled(&self) -> bool {
1105 self.config.enabled
1106 }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112
1113 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1121
1122 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1123 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1124 }
1125
1126 fn test_key_path(label: &str) -> std::path::PathBuf {
1135 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1136 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1137 let path = std::env::temp_dir().join(format!(
1138 "lit_enc_test_{}_{}_{}.key",
1139 std::process::id(),
1140 label,
1141 n
1142 ));
1143 let _ = fs::remove_file(&path);
1144 path
1145 }
1146
1147 #[test]
1148 fn test_key_derivation() {
1149 let passphrase = "test-passphrase-12345";
1150 let salt = EncryptionKey::generate_salt();
1151
1152 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1153 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1154
1155 assert_eq!(key1.as_bytes(), key2.as_bytes());
1157 }
1158
1159 #[test]
1160 fn test_encryption_decryption() {
1161 let passphrase = "test-secure-passphrase";
1162 let salt = EncryptionKey::generate_salt();
1163 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1164
1165 let engine = EncryptionEngine::new(&key).unwrap();
1166
1167 let plaintext = b"Hello, this is secret data!";
1168
1169 let encrypted = engine.encrypt(plaintext).unwrap();
1171
1172 assert_ne!(encrypted.as_slice(), plaintext);
1174
1175 let decrypted = engine.decrypt(&encrypted).unwrap();
1177
1178 assert_eq!(decrypted.as_slice(), plaintext);
1180 }
1181
1182 #[test]
1183 fn test_encryption_nonce_randomness() {
1184 let passphrase = "test-passphrase";
1185 let salt = EncryptionKey::generate_salt();
1186 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1187
1188 let engine = EncryptionEngine::new(&key).unwrap();
1189
1190 let plaintext = b"Same data";
1191
1192 let encrypted1 = engine.encrypt(plaintext).unwrap();
1194 let encrypted2 = engine.encrypt(plaintext).unwrap();
1195
1196 assert_ne!(encrypted1, encrypted2);
1198
1199 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1201 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1202 }
1203
1204 #[test]
1205 fn test_tampering_detection() {
1206 let passphrase = "test-passphrase";
1207 let salt = EncryptionKey::generate_salt();
1208 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1209
1210 let engine = EncryptionEngine::new(&key).unwrap();
1211
1212 let plaintext = b"Secret data";
1213 let mut encrypted = engine.encrypt(plaintext).unwrap();
1214
1215 let len = encrypted.len();
1217 encrypted[len - 1] ^= 0x01;
1218
1219 assert!(engine.decrypt(&encrypted).is_err());
1221 }
1222
1223 #[test]
1224 fn test_encryption_manager_disabled() {
1225 let config = EncryptionConfig {
1226 enabled: false,
1227 ..Default::default()
1228 };
1229
1230 let manager = EncryptionManager::new(config);
1231
1232 let data = b"Some data";
1233
1234 assert_eq!(manager.encrypt(data).unwrap(), data);
1236 assert_eq!(manager.decrypt(data).unwrap(), data);
1237 }
1238
1239 #[test]
1240 fn test_passphrase_caching() {
1241 let _guard = cache_test_guard();
1242 let repo_path = "/tmp/test-repo";
1243 let passphrase = "cache-test-passphrase".to_string();
1244
1245 clear_passphrase_cache();
1247
1248 assert!(get_cached_passphrase(repo_path).is_none());
1250
1251 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1253
1254 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1256
1257 clear_cached_passphrase(repo_path);
1259 assert!(get_cached_passphrase(repo_path).is_none());
1260 }
1261
1262 #[test]
1263 fn test_passphrase_cache_expiration() {
1264 let _guard = cache_test_guard();
1265 let repo_path = "/tmp/test-repo-expire";
1266 let passphrase = "expire-test".to_string();
1267
1268 clear_passphrase_cache();
1269
1270 cache_passphrase(
1277 repo_path,
1278 passphrase.clone(),
1279 Some(Duration::from_millis(200)),
1280 );
1281
1282 std::thread::sleep(Duration::from_millis(600));
1283
1284 assert!(get_cached_passphrase(repo_path).is_none());
1286 }
1287
1288 #[test]
1289 fn test_passphrase_cache_multiple_repos() {
1290 let _guard = cache_test_guard();
1291 let repo1 = "/tmp/multi-cache-repo1";
1292 let repo2 = "/tmp/multi-cache-repo2";
1293 let pass1 = "password1".to_string();
1294 let pass2 = "password2".to_string();
1295
1296 clear_passphrase_cache();
1297
1298 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1300 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1301
1302 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1304 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1305 }
1306
1307 #[test]
1308 fn test_encryption_manager_with_cache() {
1309 use std::env;
1310
1311 let _guard = cache_test_guard();
1312
1313 let key_file = test_key_path("manager_cache");
1314
1315 let temp_dir = env::temp_dir();
1316 let repo_path = temp_dir.join("test-cache-manager");
1317 let repo_str = repo_path.to_str().unwrap();
1318
1319 clear_passphrase_cache();
1320
1321 let config = EncryptionConfig {
1322 enabled: true,
1323 key_file: key_file.to_string_lossy().into_owned(),
1324 cache_timeout_secs: 300, ..Default::default()
1326 };
1327
1328 let mut manager = EncryptionManager::new(config);
1329 let passphrase = "test-cache-manager-pass";
1330
1331 manager
1333 .initialize_with_cache(repo_str, Some(passphrase))
1334 .unwrap();
1335
1336 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1338
1339 let mut manager2 = EncryptionManager::new(manager.config.clone());
1341 manager2.initialize_with_cache(repo_str, None).unwrap();
1342
1343 clear_passphrase_cache();
1345 let _ = fs::remove_file(&key_file);
1346 }
1347
1348 #[test]
1354 #[ignore]
1355 fn test_rate_limiting() {
1356 let key_file = test_key_path("rate_limiting");
1357 let key_file_str = key_file.to_string_lossy().into_owned();
1358
1359 let passphrase = "correct-passphrase-1234567890";
1362 let salt = EncryptionKey::generate_salt();
1363 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1364 key.save(&key_file_str, passphrase).unwrap();
1365
1366 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1368
1369 let start = std::time::Instant::now();
1376 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1377 .err()
1378 .expect("an attempt inside the backoff window must be refused");
1379 assert!(
1380 throttled.contains("wait"),
1381 "expected a rate-limit refusal, got: {}",
1382 throttled
1383 );
1384 assert!(
1385 start.elapsed() < Duration::from_secs(1),
1386 "the throttle should refuse immediately rather than block the caller"
1387 );
1388
1389 std::thread::sleep(Duration::from_millis(2_100));
1392 let correct = EncryptionKey::load(&key_file, passphrase);
1393 assert!(
1394 correct.is_ok(),
1395 "the correct passphrase should be accepted once the window passes: {:?}",
1396 correct.as_ref().err()
1397 );
1398
1399 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1402 .err()
1403 .expect("a wrong passphrase must still fail");
1404 assert!(
1405 !after_reset.contains("wait"),
1406 "a successful load should reset the counter, got: {}",
1407 after_reset
1408 );
1409
1410 let _ = fs::remove_file(&key_file);
1411 }
1412
1413 #[test]
1422 fn test_nonces_do_not_repeat_across_engines() {
1423 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1424
1425 let mut nonces = std::collections::HashSet::new();
1426 let mut leading_zero_runs = 0;
1427
1428 for _ in 0..64 {
1429 let engine = EncryptionEngine::new(&key).unwrap();
1431 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1432 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1433
1434 if nonce[..8] == [0u8; 8] {
1435 leading_zero_runs += 1;
1436 }
1437 assert!(
1438 nonces.insert(nonce),
1439 "a nonce repeated across engines, which breaks AES-GCM"
1440 );
1441 }
1442
1443 assert!(
1445 leading_zero_runs <= 1,
1446 "{} of 64 nonces began with eight zero bytes, which means the \
1447 counter is resetting rather than the nonce being random",
1448 leading_zero_runs
1449 );
1450 }
1451}
1452
1453#[cfg(test)]
1454mod key_file_permission_tests {
1455 use super::*;
1456
1457 #[test]
1464 fn test_key_file_can_be_saved_over() {
1465 let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1466 let _ = fs::remove_file(&path);
1467 let path_str = path.to_string_lossy().to_string();
1468
1469 let first =
1470 EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1471 first
1472 .save(&path_str, "FirstPassphrase!123")
1473 .expect("first save should succeed");
1474
1475 let second =
1476 EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1477 second
1478 .save(&path_str, "SecondPassphrase!234")
1479 .expect("saving over an existing key file should succeed, as rotate-key does");
1480
1481 let stored = fs::read(&path).unwrap();
1483 assert_eq!(
1484 &stored[..SALT_SIZE],
1485 &[2u8; SALT_SIZE],
1486 "the rewrite should have taken effect"
1487 );
1488
1489 let _ = fs::remove_file(&path);
1490 }
1491
1492 #[test]
1497 fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1498 let dir = tempfile::tempdir().unwrap();
1499 let path = dir.path().join("preexisting.key");
1500 fs::write(&path, b"secret").unwrap();
1501
1502 #[cfg(unix)]
1503 {
1504 use std::os::unix::fs::PermissionsExt;
1505 let mut perms = fs::metadata(&path).unwrap().permissions();
1506 perms.set_mode(0o644);
1507 fs::set_permissions(&path, perms).unwrap();
1508
1509 restrict_to_owner(&path).unwrap();
1510
1511 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1512 assert_eq!(mode, 0o600, "group and other should have lost all access");
1513 }
1514
1515 #[cfg(windows)]
1516 restrict_to_owner(&path).unwrap();
1517
1518 assert_eq!(fs::read(&path).unwrap(), b"secret");
1521 }
1522}