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 let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
598 .map_err(|e| format!("Failed to create cipher: {}", e))?;
599
600 Ok(EncryptionEngine {
601 cipher,
602 nonce_counter: AtomicU64::new(0),
603 })
604 }
605
606 #[allow(deprecated)]
611 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
612 let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
619 if count >= MAX_ENCRYPTIONS_PER_KEY {
620 return Err(format!(
621 "Encryption limit exceeded ({} operations). Key rotation required for security.",
622 MAX_ENCRYPTIONS_PER_KEY
623 ));
624 }
625
626 use aes_gcm::aead::rand_core::RngCore;
643 let mut nonce_bytes = [0u8; NONCE_SIZE];
644 OsRng.fill_bytes(&mut nonce_bytes);
645 let nonce = Nonce::from_slice(&nonce_bytes);
646
647 let ciphertext = self
649 .cipher
650 .encrypt(nonce, plaintext)
651 .map_err(|e| format!("Encryption failed: {}", e))?;
652
653 let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
655 output.push(ENCRYPTION_VERSION);
656 output.extend_from_slice(&nonce_bytes);
657 output.extend_from_slice(&ciphertext);
658
659 Ok(output)
660 }
661
662 #[allow(deprecated)]
664 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
665 if encrypted.len() < 1 + NONCE_SIZE {
666 return Err("Invalid encrypted data: too short".to_string());
667 }
668
669 let version = encrypted[0];
671 if version != ENCRYPTION_VERSION {
672 return Err(format!("Unsupported encryption version: {}", version));
673 }
674
675 let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
677 let nonce = Nonce::from_slice(nonce_bytes);
678
679 let ciphertext = &encrypted[1 + NONCE_SIZE..];
681
682 let plaintext = self
684 .cipher
685 .decrypt(nonce, ciphertext)
686 .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
687
688 Ok(plaintext)
689 }
690}
691
692impl CachedPassphrase {
694 fn is_valid(&self) -> bool {
696 SystemTime::now() < self.expires_at
697 }
698}
699
700pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
703 let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
704 let expires_at = SystemTime::now() + timeout;
705
706 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
707 cache.insert(
708 repo_path.to_string(),
709 CachedPassphrase {
710 passphrase: Zeroizing::new(passphrase),
711 expires_at,
712 },
713 );
714 }
715}
716
717pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
720 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
721 if let Some(entry) = cache.get(repo_path) {
722 if entry.is_valid() {
723 return Some(entry.passphrase.clone());
724 } else {
725 cache.remove(repo_path);
727 }
728 }
729 }
730 None
731}
732
733pub fn clear_passphrase_cache() {
735 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
736 cache.clear();
737 }
738}
739
740pub fn clear_cached_passphrase(repo_path: &str) {
742 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
743 cache.remove(repo_path);
744 }
745}
746
747fn get_passphrase_non_interactive(
753 repo_path: &str,
754 config: &EncryptionConfig,
755) -> Option<Zeroizing<String>> {
756 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
758 if !pass.is_empty() {
759 return Some(Zeroizing::new(pass));
760 }
761 }
762
763 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
765 if let Ok(pass) = std::fs::read_to_string(&path) {
766 let pass = pass
767 .trim_end_matches('\n')
768 .trim_end_matches('\r')
769 .to_string();
770 if !pass.is_empty() {
771 return Some(Zeroizing::new(pass));
772 }
773 }
774 }
775
776 if config.cache_timeout_secs > 0 {
778 if let Some(cached) = get_cached_passphrase(repo_path) {
779 return Some(cached);
780 }
781 }
782
783 None
784}
785
786pub fn prompt_for_passphrase(
792 repo_path: &str,
793 config: &EncryptionConfig,
794 prompt_text: &str,
795) -> Result<Zeroizing<String>, String> {
796 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
798 return Ok(pass);
799 }
800
801 if !std::io::stdin().is_terminal() {
804 return Err(
805 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
806 LIT_PASSPHRASE_FILE"
807 .to_string(),
808 );
809 }
810
811 rpassword::prompt_password(prompt_text)
813 .map(Zeroizing::new)
814 .map_err(|e| format!("Failed to read passphrase: {}", e))
815}
816
817const MIN_PASSPHRASE_LENGTH: usize = 16;
819
820fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
826 #[cfg(test)]
828 if passphrase.starts_with("test-") {
829 return Ok(());
830 }
831
832 if passphrase.len() < MIN_PASSPHRASE_LENGTH {
833 return Err(format!(
834 "Passphrase must be at least {} characters (recommended: 20+)",
835 MIN_PASSPHRASE_LENGTH
836 ));
837 }
838
839 let has_upper = passphrase.chars().any(|c| c.is_uppercase());
841 let has_lower = passphrase.chars().any(|c| c.is_lowercase());
842 let has_digit = passphrase.chars().any(|c| c.is_numeric());
843 let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
844
845 let complexity_count = [has_upper, has_lower, has_digit, has_special]
846 .iter()
847 .filter(|&&x| x)
848 .count();
849
850 if complexity_count < 3 {
851 return Err(
852 "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
853 .to_string(),
854 );
855 }
856
857 Ok(())
858}
859
860pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
864 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
866 if !pass.is_empty() {
867 validate_passphrase_strength(&pass)?;
868 return Ok(Zeroizing::new(pass));
869 }
870 }
871
872 if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
874 if let Ok(pass) = std::fs::read_to_string(&path) {
875 let pass = pass
876 .trim_end_matches('\n')
877 .trim_end_matches('\r')
878 .to_string();
879 if !pass.is_empty() {
880 validate_passphrase_strength(&pass)?;
881 return Ok(Zeroizing::new(pass));
882 }
883 }
884 }
885
886 if !std::io::stdin().is_terminal() {
888 return Err(
889 "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
890 LIT_PASSPHRASE_FILE"
891 .to_string(),
892 );
893 }
894
895 let pass1 = rpassword::prompt_password(prompt_text)
897 .map_err(|e| format!("Failed to read passphrase: {}", e))?;
898
899 let pass2 = rpassword::prompt_password("Confirm passphrase: ")
900 .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
901
902 if pass1 != pass2 {
903 return Err("Passphrases do not match".to_string());
904 }
905
906 validate_passphrase_strength(&pass1)?;
907
908 Ok(Zeroizing::new(pass1))
909}
910
911pub struct EncryptionManager {
913 config: EncryptionConfig,
914 engine: Option<EncryptionEngine>,
915 repo_path: Option<String>,
916}
917
918impl EncryptionManager {
919 pub fn new(config: EncryptionConfig) -> Self {
921 EncryptionManager {
922 config,
923 engine: None,
924 repo_path: None,
925 }
926 }
927
928 pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
941 let mut manager = EncryptionManager::new(config);
942 if !manager.config.enabled {
943 return manager;
944 }
945
946 let repo = repo_path.to_string_lossy().to_string();
947 let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
948 return manager;
949 };
950
951 manager.repo_path = Some(repo.clone());
952 if let Err(e) = manager.initialize(&passphrase) {
953 eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
954 return manager;
955 }
956
957 if manager.config.cache_timeout_secs > 0 {
958 let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
959 cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
960 }
961
962 manager
963 }
964
965 pub fn is_encrypted_payload(data: &[u8]) -> bool {
972 data.first() == Some(&ENCRYPTION_VERSION)
973 }
974
975 pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
977 if !self.config.enabled {
978 return Ok(());
979 }
980
981 let expanded = shellexpand::tilde(&self.config.key_file);
982 let key_file = Path::new(expanded.as_ref());
983
984 let cache_id = derived_key_id(expanded.as_ref(), passphrase);
995 if let Some(key) = cached_derived_key(&cache_id) {
996 self.engine = Some(EncryptionEngine::new(&key)?);
997 return Ok(());
998 }
999
1000 let key = if key_file.exists() {
1002 EncryptionKey::load(key_file, passphrase)?
1003 } else {
1004 let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1005 key.save(&self.config.key_file, passphrase)?;
1006 key
1007 };
1008
1009 let key = std::sync::Arc::new(key);
1010 remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1011
1012 self.engine = Some(EncryptionEngine::new(&key)?);
1014
1015 Ok(())
1016 }
1017
1018 pub fn initialize_with_cache(
1020 &mut self,
1021 repo_path: &str,
1022 passphrase: Option<&str>,
1023 ) -> Result<(), String> {
1024 if !self.config.enabled {
1025 return Ok(());
1026 }
1027
1028 self.repo_path = Some(repo_path.to_string());
1029
1030 let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1032 Zeroizing::new(pass.to_string())
1033 } else if let Some(cached) = get_cached_passphrase(repo_path) {
1034 cached
1035 } else {
1036 return Err("No passphrase provided and no valid cached passphrase found".to_string());
1037 };
1038
1039 self.initialize(&actual_passphrase)?;
1041
1042 if self.config.cache_timeout_secs > 0 {
1044 let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1045 cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1046 }
1047
1048 Ok(())
1049 }
1050
1051 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1053 if !self.config.enabled {
1054 return Ok(plaintext.to_vec());
1055 }
1056
1057 match &self.engine {
1058 Some(engine) => engine.encrypt(plaintext),
1059 None => Err(
1060 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1061 ),
1062 }
1063 }
1064
1065 pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1067 if !self.config.enabled {
1068 return Ok(encrypted.to_vec());
1069 }
1070
1071 match &self.engine {
1072 Some(engine) => {
1073 if encrypted
1080 .first()
1081 .is_some_and(|version| *version != ENCRYPTION_VERSION)
1082 {
1083 return Err(
1084 "This data has no Lit encryption header. Encryption cannot be \
1085 enabled for a repository that already contains unencrypted \
1086 commits — start a new encrypted repository and import into it."
1087 .to_string(),
1088 );
1089 }
1090 engine.decrypt(encrypted)
1091 }
1092 None => Err(
1093 "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1094 ),
1095 }
1096 }
1097
1098 pub fn is_enabled(&self) -> bool {
1100 self.config.enabled
1101 }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::*;
1107
1108 static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1116
1117 fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1118 CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1119 }
1120
1121 fn test_key_path(label: &str) -> std::path::PathBuf {
1130 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1131 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1132 let path = std::env::temp_dir().join(format!(
1133 "lit_enc_test_{}_{}_{}.key",
1134 std::process::id(),
1135 label,
1136 n
1137 ));
1138 let _ = fs::remove_file(&path);
1139 path
1140 }
1141
1142 #[test]
1143 fn test_key_derivation() {
1144 let passphrase = "test-passphrase-12345";
1145 let salt = EncryptionKey::generate_salt();
1146
1147 let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1148 let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1149
1150 assert_eq!(key1.as_bytes(), key2.as_bytes());
1152 }
1153
1154 #[test]
1155 fn test_encryption_decryption() {
1156 let passphrase = "test-secure-passphrase";
1157 let salt = EncryptionKey::generate_salt();
1158 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1159
1160 let engine = EncryptionEngine::new(&key).unwrap();
1161
1162 let plaintext = b"Hello, this is secret data!";
1163
1164 let encrypted = engine.encrypt(plaintext).unwrap();
1166
1167 assert_ne!(encrypted.as_slice(), plaintext);
1169
1170 let decrypted = engine.decrypt(&encrypted).unwrap();
1172
1173 assert_eq!(decrypted.as_slice(), plaintext);
1175 }
1176
1177 #[test]
1178 fn test_encryption_nonce_randomness() {
1179 let passphrase = "test-passphrase";
1180 let salt = EncryptionKey::generate_salt();
1181 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1182
1183 let engine = EncryptionEngine::new(&key).unwrap();
1184
1185 let plaintext = b"Same data";
1186
1187 let encrypted1 = engine.encrypt(plaintext).unwrap();
1189 let encrypted2 = engine.encrypt(plaintext).unwrap();
1190
1191 assert_ne!(encrypted1, encrypted2);
1193
1194 assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1196 assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1197 }
1198
1199 #[test]
1200 fn test_tampering_detection() {
1201 let passphrase = "test-passphrase";
1202 let salt = EncryptionKey::generate_salt();
1203 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1204
1205 let engine = EncryptionEngine::new(&key).unwrap();
1206
1207 let plaintext = b"Secret data";
1208 let mut encrypted = engine.encrypt(plaintext).unwrap();
1209
1210 let len = encrypted.len();
1212 encrypted[len - 1] ^= 0x01;
1213
1214 assert!(engine.decrypt(&encrypted).is_err());
1216 }
1217
1218 #[test]
1219 fn test_encryption_manager_disabled() {
1220 let config = EncryptionConfig {
1221 enabled: false,
1222 ..Default::default()
1223 };
1224
1225 let manager = EncryptionManager::new(config);
1226
1227 let data = b"Some data";
1228
1229 assert_eq!(manager.encrypt(data).unwrap(), data);
1231 assert_eq!(manager.decrypt(data).unwrap(), data);
1232 }
1233
1234 #[test]
1235 fn test_passphrase_caching() {
1236 let _guard = cache_test_guard();
1237 let repo_path = "/tmp/test-repo";
1238 let passphrase = "cache-test-passphrase".to_string();
1239
1240 clear_passphrase_cache();
1242
1243 assert!(get_cached_passphrase(repo_path).is_none());
1245
1246 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1248
1249 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1251
1252 clear_cached_passphrase(repo_path);
1254 assert!(get_cached_passphrase(repo_path).is_none());
1255 }
1256
1257 #[test]
1258 fn test_passphrase_cache_expiration() {
1259 let _guard = cache_test_guard();
1260 let repo_path = "/tmp/test-repo-expire";
1261 let passphrase = "expire-test".to_string();
1262
1263 clear_passphrase_cache();
1264
1265 cache_passphrase(
1272 repo_path,
1273 passphrase.clone(),
1274 Some(Duration::from_millis(200)),
1275 );
1276
1277 std::thread::sleep(Duration::from_millis(600));
1278
1279 assert!(get_cached_passphrase(repo_path).is_none());
1281 }
1282
1283 #[test]
1284 fn test_passphrase_cache_multiple_repos() {
1285 let _guard = cache_test_guard();
1286 let repo1 = "/tmp/multi-cache-repo1";
1287 let repo2 = "/tmp/multi-cache-repo2";
1288 let pass1 = "password1".to_string();
1289 let pass2 = "password2".to_string();
1290
1291 clear_passphrase_cache();
1292
1293 cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1295 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1296
1297 assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1299 assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1300 }
1301
1302 #[test]
1303 fn test_encryption_manager_with_cache() {
1304 use std::env;
1305
1306 let _guard = cache_test_guard();
1307
1308 let key_file = test_key_path("manager_cache");
1309
1310 let temp_dir = env::temp_dir();
1311 let repo_path = temp_dir.join("test-cache-manager");
1312 let repo_str = repo_path.to_str().unwrap();
1313
1314 clear_passphrase_cache();
1315
1316 let config = EncryptionConfig {
1317 enabled: true,
1318 key_file: key_file.to_string_lossy().into_owned(),
1319 cache_timeout_secs: 300, ..Default::default()
1321 };
1322
1323 let mut manager = EncryptionManager::new(config);
1324 let passphrase = "test-cache-manager-pass";
1325
1326 manager
1328 .initialize_with_cache(repo_str, Some(passphrase))
1329 .unwrap();
1330
1331 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1333
1334 let mut manager2 = EncryptionManager::new(manager.config.clone());
1336 manager2.initialize_with_cache(repo_str, None).unwrap();
1337
1338 clear_passphrase_cache();
1340 let _ = fs::remove_file(&key_file);
1341 }
1342
1343 #[test]
1349 #[ignore]
1350 fn test_rate_limiting() {
1351 let key_file = test_key_path("rate_limiting");
1352 let key_file_str = key_file.to_string_lossy().into_owned();
1353
1354 let passphrase = "correct-passphrase-1234567890";
1357 let salt = EncryptionKey::generate_salt();
1358 let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1359 key.save(&key_file_str, passphrase).unwrap();
1360
1361 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1363
1364 let start = std::time::Instant::now();
1371 let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1372 .err()
1373 .expect("an attempt inside the backoff window must be refused");
1374 assert!(
1375 throttled.contains("wait"),
1376 "expected a rate-limit refusal, got: {}",
1377 throttled
1378 );
1379 assert!(
1380 start.elapsed() < Duration::from_secs(1),
1381 "the throttle should refuse immediately rather than block the caller"
1382 );
1383
1384 std::thread::sleep(Duration::from_millis(2_100));
1387 let correct = EncryptionKey::load(&key_file, passphrase);
1388 assert!(
1389 correct.is_ok(),
1390 "the correct passphrase should be accepted once the window passes: {:?}",
1391 correct.as_ref().err()
1392 );
1393
1394 let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1397 .err()
1398 .expect("a wrong passphrase must still fail");
1399 assert!(
1400 !after_reset.contains("wait"),
1401 "a successful load should reset the counter, got: {}",
1402 after_reset
1403 );
1404
1405 let _ = fs::remove_file(&key_file);
1406 }
1407
1408 #[test]
1417 fn test_nonces_do_not_repeat_across_engines() {
1418 let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1419
1420 let mut nonces = std::collections::HashSet::new();
1421 let mut leading_zero_runs = 0;
1422
1423 for _ in 0..64 {
1424 let engine = EncryptionEngine::new(&key).unwrap();
1426 let blob = engine.encrypt(b"same plaintext every time").unwrap();
1427 let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1428
1429 if nonce[..8] == [0u8; 8] {
1430 leading_zero_runs += 1;
1431 }
1432 assert!(
1433 nonces.insert(nonce),
1434 "a nonce repeated across engines, which breaks AES-GCM"
1435 );
1436 }
1437
1438 assert!(
1440 leading_zero_runs <= 1,
1441 "{} of 64 nonces began with eight zero bytes, which means the \
1442 counter is resetting rather than the nonce being random",
1443 leading_zero_runs
1444 );
1445 }
1446}
1447
1448#[cfg(test)]
1449mod key_file_permission_tests {
1450 use super::*;
1451
1452 #[test]
1459 fn test_key_file_can_be_saved_over() {
1460 let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1461 let _ = fs::remove_file(&path);
1462 let path_str = path.to_string_lossy().to_string();
1463
1464 let first =
1465 EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1466 first
1467 .save(&path_str, "FirstPassphrase!123")
1468 .expect("first save should succeed");
1469
1470 let second =
1471 EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1472 second
1473 .save(&path_str, "SecondPassphrase!234")
1474 .expect("saving over an existing key file should succeed, as rotate-key does");
1475
1476 let stored = fs::read(&path).unwrap();
1478 assert_eq!(
1479 &stored[..SALT_SIZE],
1480 &[2u8; SALT_SIZE],
1481 "the rewrite should have taken effect"
1482 );
1483
1484 let _ = fs::remove_file(&path);
1485 }
1486
1487 #[test]
1492 fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1493 let dir = tempfile::tempdir().unwrap();
1494 let path = dir.path().join("preexisting.key");
1495 fs::write(&path, b"secret").unwrap();
1496
1497 #[cfg(unix)]
1498 {
1499 use std::os::unix::fs::PermissionsExt;
1500 let mut perms = fs::metadata(&path).unwrap().permissions();
1501 perms.set_mode(0o644);
1502 fs::set_permissions(&path, perms).unwrap();
1503
1504 restrict_to_owner(&path).unwrap();
1505
1506 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1507 assert_eq!(mode, 0o600, "group and other should have lost all access");
1508 }
1509
1510 #[cfg(windows)]
1511 restrict_to_owner(&path).unwrap();
1512
1513 assert_eq!(fs::read(&path).unwrap(), b"secret");
1516 }
1517}