Skip to main content

lit/crypto/
encryption.rs

1#![allow(unused_assignments)]
2/// Encryption Module - FIPS 140-3 Compliant AES-256-GCM
3/// Provides secure at-rest encryption for repository data
4///
5/// Standards Compliance:
6/// - FIPS 140-3 (ISO/IEC 19790:2012) - Cryptographic Module Validation
7/// - AES-256-GCM (FIPS 197, NIST SP 800-38D) - Authenticated Encryption
8/// - PBKDF2-HMAC-SHA512 (NIST SP 800-132) - Password-Based Key Derivation
9/// - DRBG (NIST SP 800-90A Rev. 1) - Deterministic Random Bit Generation
10/// - Key Management (NIST SP 800-57 Part 1 Rev. 5) - Cryptographic Key Management
11use 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
28/// AES-256 key size in bytes
29const KEY_SIZE: usize = 32;
30
31/// Default passphrase cache timeout (5 minutes)
32const DEFAULT_CACHE_TIMEOUT: Duration = Duration::from_secs(300);
33
34/// Cached passphrase entry with expiration
35/// SECURITY: Uses Zeroizing to ensure passphrase is cleared from memory on drop
36struct CachedPassphrase {
37    passphrase: Zeroizing<String>,
38    expires_at: SystemTime,
39}
40
41lazy_static! {
42    /// Global passphrase cache with thread-safe access
43    static ref PASSPHRASE_CACHE: Mutex<HashMap<String, CachedPassphrase>> = Mutex::new(HashMap::new());
44
45    /// Global failed attempt tracker for rate limiting
46    static ref FAILED_ATTEMPTS: Mutex<HashMap<String, FailedAttemptTracker>> = Mutex::new(HashMap::new());
47}
48
49/// Tracks failed passphrase attempts for rate limiting
50struct FailedAttemptTracker {
51    count: u32,
52    last_attempt: SystemTime,
53    lockout_until: Option<SystemTime>,
54}
55
56/// AES-GCM nonce size in bytes (96 bits recommended)
57const NONCE_SIZE: usize = 12;
58
59/// PBKDF2 iteration count for FIPS 140-3 compliance
60/// NIST SP 800-132 (2010) recommends minimum 10,000
61/// NIST SP 800-63B (2024) recommends minimum 210,000
62/// We use 600,000 for enhanced security against modern GPU attacks
63/// This provides ~2.85x the current NIST recommendation
64const PBKDF2_ITERATIONS: u32 = 600_000;
65
66/// Salt size for PBKDF2 (16 bytes = 128 bits)
67/// Meets NIST SP 800-132 requirement for >= 128 bits
68const SALT_SIZE: usize = 16;
69
70/// Encrypted data header version
71const ENCRYPTION_VERSION: u8 = 1;
72
73/// Encryption configuration
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct EncryptionConfig {
76    /// Enable encryption for repository data
77    pub enabled: bool,
78    /// Path to encrypted key file
79    pub key_file: String,
80    /// FIPS 140-3 mode (strict algorithm compliance)
81    pub fips_mode: bool,
82    /// Passphrase cache timeout in seconds (0 to disable caching)
83    #[serde(default = "default_cache_timeout")]
84    pub cache_timeout_secs: u64,
85}
86
87fn default_cache_timeout() -> u64 {
88    300 // 5 minutes
89}
90
91impl Default for EncryptionConfig {
92    fn default() -> Self {
93        EncryptionConfig {
94            enabled: false,
95            key_file: "~/.lit/encryption.key".to_string(),
96            fips_mode: true,
97            cache_timeout_secs: default_cache_timeout(),
98        }
99    }
100}
101
102impl EncryptionConfig {
103    /// Load configuration from repository
104    pub fn load(repo_path: &Path) -> Result<Self, String> {
105        let config_path = repo_path.join(".lit").join("encryption.toml");
106
107        if !config_path.exists() {
108            return Ok(Self::default());
109        }
110
111        let content = fs::read_to_string(&config_path)
112            .map_err(|e| format!("Failed to read encryption config: {}", e))?;
113
114        toml::from_str(&content).map_err(|e| format!("Failed to parse encryption config: {}", e))
115    }
116
117    /// Save configuration to repository
118    pub fn save(&self, repo_path: &Path) -> Result<(), String> {
119        let config_path = repo_path.join(".lit").join("encryption.toml");
120
121        let content = toml::to_string_pretty(self)
122            .map_err(|e| format!("Failed to serialize encryption config: {}", e))?;
123
124        fs::write(&config_path, content)
125            .map_err(|e| format!("Failed to write encryption config: {}", e))
126    }
127}
128
129/// Check rate limit for passphrase attempts
130/// Returns Ok(()) if attempt is allowed, Err with message if rate limited
131fn check_rate_limit(repo_path: &str) -> Result<(), String> {
132    let mut attempts = FAILED_ATTEMPTS
133        .lock()
134        .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
135    let tracker = attempts
136        .entry(repo_path.to_string())
137        .or_insert_with(|| FailedAttemptTracker {
138            count: 0,
139            last_attempt: SystemTime::now(),
140            lockout_until: None,
141        });
142
143    // Check if currently locked out
144    if let Some(lockout) = tracker.lockout_until {
145        if SystemTime::now() < lockout {
146            let remaining = lockout
147                .duration_since(SystemTime::now())
148                .unwrap_or(Duration::from_secs(0));
149            return Err(format!(
150                "Too many failed attempts. Please wait {} seconds before trying again.",
151                remaining.as_secs()
152            ));
153        }
154        // Lockout expired, reset counter
155        tracker.lockout_until = None;
156        tracker.count = 0;
157    }
158
159    // Apply exponential backoff: 2^n seconds (max 32 seconds for n=5)
160    if tracker.count > 0 {
161        let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
162        if let Ok(elapsed) = tracker.last_attempt.elapsed() {
163            if elapsed < delay {
164                let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
165                return Err(format!(
166                    "Please wait {} seconds between passphrase attempts.",
167                    remaining
168                ));
169            }
170        }
171    }
172
173    Ok(())
174}
175
176/// Record a failed passphrase attempt
177fn record_failed_attempt(repo_path: &str) {
178    let Ok(mut attempts) = FAILED_ATTEMPTS.lock() else {
179        return;
180    };
181    let tracker = attempts
182        .entry(repo_path.to_string())
183        .or_insert_with(|| FailedAttemptTracker {
184            count: 0,
185            last_attempt: SystemTime::now(),
186            lockout_until: None,
187        });
188
189    tracker.count += 1;
190    tracker.last_attempt = SystemTime::now();
191
192    // Lock out for 5 minutes after 5 failed attempts
193    if tracker.count >= 5 {
194        tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
195        eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
196    }
197}
198
199/// Clear failed attempt counter (called on successful authentication)
200fn clear_failed_attempts(repo_path: &str) {
201    if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
202        attempts.remove(repo_path);
203    }
204}
205
206/// Secure encryption key with automatic zeroization
207#[derive(ZeroizeOnDrop)]
208#[allow(unused_assignments)]
209pub struct EncryptionKey {
210    key_bytes: [u8; KEY_SIZE],
211    /// Salt used to derive this key (needed for saving)
212    #[zeroize(skip)]
213    salt: [u8; SALT_SIZE],
214}
215
216impl EncryptionKey {
217    /// Derive key from passphrase using PBKDF2-HMAC-SHA512
218    pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
219        // SECURITY: Test bypass only available in test builds (FINDING-001)
220        #[cfg(not(test))]
221        validate_passphrase_strength(passphrase)?;
222        #[cfg(test)]
223        if !passphrase.starts_with("test-") {
224            validate_passphrase_strength(passphrase)?;
225        }
226
227        if salt.len() != SALT_SIZE {
228            return Err(format!(
229                "Invalid salt size: expected {}, got {}",
230                SALT_SIZE,
231                salt.len()
232            ));
233        }
234
235        let mut key_bytes = [0u8; KEY_SIZE];
236        pbkdf2_hmac::<Sha512>(
237            passphrase.as_bytes(),
238            salt,
239            PBKDF2_ITERATIONS,
240            &mut key_bytes,
241        );
242
243        let mut salt_array = [0u8; SALT_SIZE];
244        salt_array.copy_from_slice(salt);
245
246        Ok(EncryptionKey {
247            key_bytes,
248            salt: salt_array,
249        })
250    }
251
252    /// Generate a random salt for key derivation
253    pub fn generate_salt() -> [u8; SALT_SIZE] {
254        use aes_gcm::aead::rand_core::RngCore;
255        let mut salt = [0u8; SALT_SIZE];
256        OsRng.fill_bytes(&mut salt);
257        salt
258    }
259
260    /// Load key from encrypted key file
261    /// SECURITY: Verifies passphrase using stored hash (constant-time comparison)
262    /// SECURITY: Rate limiting prevents brute force attacks
263    pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
264        // SECURITY: Rate limit check — test bypass only in test builds (FINDING-001)
265        let key_file_str = key_file.to_string_lossy().to_string();
266        #[cfg(not(test))]
267        check_rate_limit(&key_file_str)?;
268        #[cfg(test)]
269        if !passphrase.starts_with("test-") {
270            check_rate_limit(&key_file_str)?;
271        }
272
273        if !key_file.exists() {
274            return Err(
275                "Encryption key file not found. Initialize repository with encryption first."
276                    .to_string(),
277            );
278        }
279
280        let encrypted_data =
281            fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
282
283        if encrypted_data.len() < SALT_SIZE + 1 {
284            return Err("Invalid key file format (too short)".to_string());
285        }
286
287        // Extract components
288        let salt = &encrypted_data[0..SALT_SIZE];
289        let version = encrypted_data[SALT_SIZE];
290
291        if version != ENCRYPTION_VERSION {
292            return Err(format!("Unsupported key file version: {}", version));
293        }
294
295        // Check if old format (no verification hash) or new format
296        if encrypted_data.len() == SALT_SIZE + 1 {
297            // Old format - just derive key (backward compatibility)
298            let key = Self::from_passphrase(passphrase, salt)?;
299            // Clear failed attempts on successful load
300            clear_failed_attempts(&key_file_str);
301            return Ok(key);
302        }
303
304        if encrypted_data.len() < SALT_SIZE + 1 + 32 {
305            return Err("Invalid key file format (unexpected size)".to_string());
306        }
307
308        let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
309
310        // Derive key from passphrase
311        let key = Self::from_passphrase(passphrase, salt)?;
312
313        // Verify passphrase using constant-time comparison
314        use sha2::{Digest, Sha256};
315        let mut hasher = Sha256::new();
316        hasher.update(b"lit-passphrase-verification-v1");
317        hasher.update(&key.key_bytes);
318        let verification_hash = hasher.finalize();
319
320        // Constant-time comparison to prevent timing attacks
321        use subtle::ConstantTimeEq;
322        if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
323            // SECURITY: Record failed attempt — test bypass only in test builds (FINDING-001)
324            #[cfg(not(test))]
325            record_failed_attempt(&key_file_str);
326            #[cfg(test)]
327            if !passphrase.starts_with("test-") {
328                record_failed_attempt(&key_file_str);
329            }
330            // Add delay to prevent timing-based passphrase enumeration
331            std::thread::sleep(std::time::Duration::from_millis(100));
332            return Err("Invalid passphrase".to_string());
333        }
334
335        // Clear failed attempts on successful authentication
336        clear_failed_attempts(&key_file_str);
337        Ok(key)
338    }
339
340    /// Save key to encrypted key file
341    /// SECURITY: Uses atomic write (temp file + rename) to prevent corruption
342    /// on crash or power loss. Includes verification hash for passphrase validation.
343    pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
344        let expanded = shellexpand::tilde(key_file_str);
345        let key_file = Path::new(expanded.as_ref());
346
347        // Generate verification hash using current key
348        use sha2::{Digest, Sha256};
349        let mut hasher = Sha256::new();
350        hasher.update(b"lit-passphrase-verification-v1");
351        hasher.update(self.key_bytes);
352        let verification_hash = hasher.finalize();
353
354        // Create key file directory if needed
355        if let Some(parent) = key_file.parent() {
356            fs::create_dir_all(parent)
357                .map_err(|e| format!("Failed to create key directory: {}", e))?;
358        }
359
360        // Store: salt + version + verification_hash
361        let mut data = Vec::new();
362        data.extend_from_slice(&self.salt);
363        data.push(ENCRYPTION_VERSION);
364        data.extend_from_slice(&verification_hash);
365
366        // Atomic write: write to temp file then rename to prevent corruption
367        let temp_file = key_file.with_extension("tmp");
368        fs::write(&temp_file, &data)
369            .map_err(|e| format!("Failed to write temp key file: {}", e))?;
370        fs::rename(&temp_file, key_file)
371            .map_err(|e| format!("Failed to rename key file: {}", e))?;
372
373        Ok(())
374    }
375
376    /// Get raw key bytes (used internally)
377    fn as_bytes(&self) -> &[u8; KEY_SIZE] {
378        &self.key_bytes
379    }
380}
381
382/// Maximum encryptions per key (NIST SP 800-38D recommendation)
383/// Never exceed 2^32 encryptions with same key to prevent nonce reuse
384const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
385
386/// Encryption engine using AES-256-GCM
387/// SECURITY: Uses atomic counter to guarantee nonce uniqueness
388pub struct EncryptionEngine {
389    cipher: Aes256Gcm,
390    /// Atomic counter for nonce generation (ensures uniqueness)
391    nonce_counter: AtomicU64,
392}
393
394impl EncryptionEngine {
395    /// Create new encryption engine with key
396    pub fn new(key: &EncryptionKey) -> Result<Self, String> {
397        let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
398            .map_err(|e| format!("Failed to create cipher: {}", e))?;
399
400        Ok(EncryptionEngine {
401            cipher,
402            nonce_counter: AtomicU64::new(0),
403        })
404    }
405
406    /// Encrypt data with authenticated encryption (AES-256-GCM)
407    ///
408    /// Format: [version: 1 byte][nonce: 12 bytes][ciphertext + auth tag]
409    /// SECURITY: Uses counter-based nonce to guarantee uniqueness
410    #[allow(deprecated)]
411    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
412        // Check encryption limit (NIST SP 800-38D)
413        let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
414        if count >= MAX_ENCRYPTIONS_PER_KEY {
415            return Err(format!(
416                "Encryption limit exceeded ({} operations). Key rotation required for security.",
417                MAX_ENCRYPTIONS_PER_KEY
418            ));
419        }
420
421        // Generate nonce: counter (8 bytes) + random (4 bytes)
422        // This guarantees uniqueness while maintaining randomness
423        use aes_gcm::aead::rand_core::RngCore;
424        let mut nonce_bytes = [0u8; NONCE_SIZE];
425        nonce_bytes[..8].copy_from_slice(&count.to_be_bytes());
426        OsRng.fill_bytes(&mut nonce_bytes[8..]);
427        let nonce = Nonce::from_slice(&nonce_bytes);
428
429        // Encrypt with authenticated encryption
430        let ciphertext = self
431            .cipher
432            .encrypt(nonce, plaintext)
433            .map_err(|e| format!("Encryption failed: {}", e))?;
434
435        // Build output: version + nonce + ciphertext
436        let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
437        output.push(ENCRYPTION_VERSION);
438        output.extend_from_slice(&nonce_bytes);
439        output.extend_from_slice(&ciphertext);
440
441        Ok(output)
442    }
443
444    /// Decrypt data with authentication verification
445    #[allow(deprecated)]
446    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
447        if encrypted.len() < 1 + NONCE_SIZE {
448            return Err("Invalid encrypted data: too short".to_string());
449        }
450
451        // Extract version
452        let version = encrypted[0];
453        if version != ENCRYPTION_VERSION {
454            return Err(format!("Unsupported encryption version: {}", version));
455        }
456
457        // Extract nonce
458        let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
459        let nonce = Nonce::from_slice(nonce_bytes);
460
461        // Extract ciphertext
462        let ciphertext = &encrypted[1 + NONCE_SIZE..];
463
464        // Decrypt and verify authentication tag
465        let plaintext = self
466            .cipher
467            .decrypt(nonce, ciphertext)
468            .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
469
470        Ok(plaintext)
471    }
472}
473
474/// Passphrase cache operations
475impl CachedPassphrase {
476    /// Check if cached passphrase is still valid
477    fn is_valid(&self) -> bool {
478        SystemTime::now() < self.expires_at
479    }
480}
481
482/// Store passphrase in cache with timeout
483/// SECURITY: Passphrase stored in Zeroizing wrapper for automatic memory clearing
484pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
485    let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
486    let expires_at = SystemTime::now() + timeout;
487
488    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
489        cache.insert(
490            repo_path.to_string(),
491            CachedPassphrase {
492                passphrase: Zeroizing::new(passphrase),
493                expires_at,
494            },
495        );
496    }
497}
498
499/// Retrieve cached passphrase if valid
500/// SECURITY: Returns clone of Zeroizing-wrapped passphrase
501pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
502    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
503        if let Some(entry) = cache.get(repo_path) {
504            if entry.is_valid() {
505                return Some(entry.passphrase.clone());
506            } else {
507                // Remove expired entry (passphrase auto-zeroized on drop)
508                cache.remove(repo_path);
509            }
510        }
511    }
512    None
513}
514
515/// Clear all cached passphrases
516pub fn clear_passphrase_cache() {
517    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
518        cache.clear();
519    }
520}
521
522/// Clear cached passphrase for specific repository
523pub fn clear_cached_passphrase(repo_path: &str) {
524    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
525        cache.remove(repo_path);
526    }
527}
528
529/// Get passphrase from non-interactive sources
530///
531/// Priority: LIT_PASSPHRASE env var > LIT_PASSPHRASE_FILE env var > cache
532/// Returns None if no non-interactive source is available.
533/// SECURITY: Returns Zeroizing<String> to ensure passphrase is cleared from memory.
534fn get_passphrase_non_interactive(
535    repo_path: &str,
536    config: &EncryptionConfig,
537) -> Option<Zeroizing<String>> {
538    // 1. Check LIT_PASSPHRASE env var
539    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
540        if !pass.is_empty() {
541            return Some(Zeroizing::new(pass));
542        }
543    }
544
545    // 2. Check LIT_PASSPHRASE_FILE env var
546    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
547        if let Ok(pass) = std::fs::read_to_string(&path) {
548            let pass = pass
549                .trim_end_matches('\n')
550                .trim_end_matches('\r')
551                .to_string();
552            if !pass.is_empty() {
553                return Some(Zeroizing::new(pass));
554            }
555        }
556    }
557
558    // 3. Check cache
559    if config.cache_timeout_secs > 0 {
560        if let Some(cached) = get_cached_passphrase(repo_path) {
561            return Some(cached);
562        }
563    }
564
565    None
566}
567
568/// Prompt user for passphrase securely via CLI
569///
570/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > cache > interactive prompt.
571/// In non-interactive mode (default for agents), returns error if no passphrase
572/// is available from env/file/cache.
573pub fn prompt_for_passphrase(
574    repo_path: &str,
575    config: &EncryptionConfig,
576    prompt_text: &str,
577) -> Result<Zeroizing<String>, String> {
578    // Try non-interactive sources first
579    if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
580        return Ok(pass);
581    }
582
583    // Agent safety: never block on an interactive prompt when there is no TTY
584    // (the default for agents, pipes, and CI). Fail fast with remediation.
585    if !std::io::stdin().is_terminal() {
586        return Err(
587            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
588             LIT_PASSPHRASE_FILE"
589                .to_string(),
590        );
591    }
592
593    // Fall back to interactive prompt
594    rpassword::prompt_password(prompt_text)
595        .map(Zeroizing::new)
596        .map_err(|e| format!("Failed to read passphrase: {}", e))
597}
598
599/// Minimum passphrase length (NIST SP 800-63B recommendation for high security)
600const MIN_PASSPHRASE_LENGTH: usize = 16;
601
602/// Validate passphrase strength
603///
604/// Requirements:
605/// - Minimum 16 characters (NIST SP 800-63B)
606/// - At least 3 of: uppercase, lowercase, digits, special characters
607fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
608    // SECURITY: Test bypass only available in test builds (FINDING-001)
609    #[cfg(test)]
610    if passphrase.starts_with("test-") {
611        return Ok(());
612    }
613
614    if passphrase.len() < MIN_PASSPHRASE_LENGTH {
615        return Err(format!(
616            "Passphrase must be at least {} characters (recommended: 20+)",
617            MIN_PASSPHRASE_LENGTH
618        ));
619    }
620
621    // Check complexity
622    let has_upper = passphrase.chars().any(|c| c.is_uppercase());
623    let has_lower = passphrase.chars().any(|c| c.is_lowercase());
624    let has_digit = passphrase.chars().any(|c| c.is_numeric());
625    let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
626
627    let complexity_count = [has_upper, has_lower, has_digit, has_special]
628        .iter()
629        .filter(|&&x| x)
630        .count();
631
632    if complexity_count < 3 {
633        return Err(
634            "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
635                .to_string(),
636        );
637    }
638
639    Ok(())
640}
641
642/// Prompt for passphrase confirmation (for new passphrases)
643///
644/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > interactive prompt (with confirmation).
645pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
646    // Check LIT_PASSPHRASE env var
647    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
648        if !pass.is_empty() {
649            validate_passphrase_strength(&pass)?;
650            return Ok(Zeroizing::new(pass));
651        }
652    }
653
654    // Check LIT_PASSPHRASE_FILE env var
655    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
656        if let Ok(pass) = std::fs::read_to_string(&path) {
657            let pass = pass
658                .trim_end_matches('\n')
659                .trim_end_matches('\r')
660                .to_string();
661            if !pass.is_empty() {
662                validate_passphrase_strength(&pass)?;
663                return Ok(Zeroizing::new(pass));
664            }
665        }
666    }
667
668    // Agent safety: never block on an interactive prompt when there is no TTY.
669    if !std::io::stdin().is_terminal() {
670        return Err(
671            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
672             LIT_PASSPHRASE_FILE"
673                .to_string(),
674        );
675    }
676
677    // Interactive prompt with confirmation
678    let pass1 = rpassword::prompt_password(prompt_text)
679        .map_err(|e| format!("Failed to read passphrase: {}", e))?;
680
681    let pass2 = rpassword::prompt_password("Confirm passphrase: ")
682        .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
683
684    if pass1 != pass2 {
685        return Err("Passphrases do not match".to_string());
686    }
687
688    validate_passphrase_strength(&pass1)?;
689
690    Ok(Zeroizing::new(pass1))
691}
692
693/// Encryption manager for repository
694pub struct EncryptionManager {
695    config: EncryptionConfig,
696    engine: Option<EncryptionEngine>,
697    repo_path: Option<String>,
698}
699
700impl EncryptionManager {
701    /// Create new encryption manager
702    pub fn new(config: EncryptionConfig) -> Self {
703        EncryptionManager {
704            config,
705            engine: None,
706            repo_path: None,
707        }
708    }
709
710    /// Initialize encryption with passphrase
711    pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
712        if !self.config.enabled {
713            return Ok(());
714        }
715
716        let expanded = shellexpand::tilde(&self.config.key_file);
717        let key_file = Path::new(expanded.as_ref());
718
719        // Load or create encryption key
720        let key = if key_file.exists() {
721            EncryptionKey::load(key_file, passphrase)?
722        } else {
723            let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
724            key.save(&self.config.key_file, passphrase)?;
725            key
726        };
727
728        // Create encryption engine
729        self.engine = Some(EncryptionEngine::new(&key)?);
730
731        Ok(())
732    }
733
734    /// Initialize encryption with passphrase caching support
735    pub fn initialize_with_cache(
736        &mut self,
737        repo_path: &str,
738        passphrase: Option<&str>,
739    ) -> Result<(), String> {
740        if !self.config.enabled {
741            return Ok(());
742        }
743
744        self.repo_path = Some(repo_path.to_string());
745
746        // Try to get cached passphrase first
747        let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
748            Zeroizing::new(pass.to_string())
749        } else if let Some(cached) = get_cached_passphrase(repo_path) {
750            cached
751        } else {
752            return Err("No passphrase provided and no valid cached passphrase found".to_string());
753        };
754
755        // Initialize encryption
756        self.initialize(&actual_passphrase)?;
757
758        // Cache the passphrase if caching is enabled
759        if self.config.cache_timeout_secs > 0 {
760            let timeout = Duration::from_secs(self.config.cache_timeout_secs);
761            cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
762        }
763
764        Ok(())
765    }
766
767    /// Encrypt data if encryption is enabled
768    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
769        if !self.config.enabled {
770            return Ok(plaintext.to_vec());
771        }
772
773        match &self.engine {
774            Some(engine) => engine.encrypt(plaintext),
775            None => Err(
776                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
777            ),
778        }
779    }
780
781    /// Decrypt data if encryption is enabled
782    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
783        if !self.config.enabled {
784            return Ok(encrypted.to_vec());
785        }
786
787        match &self.engine {
788            Some(engine) => engine.decrypt(encrypted),
789            None => Err(
790                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
791            ),
792        }
793    }
794
795    /// Check if encryption is enabled
796    pub fn is_enabled(&self) -> bool {
797        self.config.enabled
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    /// Serializes tests that mutate the process-global passphrase cache.
806    ///
807    /// Several tests call [`clear_passphrase_cache`], which wipes every entry;
808    /// running them in parallel lets one test clear another's freshly-cached
809    /// entry, producing spurious failures. Holding this lock makes those tests
810    /// mutually exclusive. Poisoning is recovered from since a panic in one
811    /// test must not cascade into the others.
812    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
813
814    fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
815        CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
816    }
817
818    #[test]
819    fn test_key_derivation() {
820        let passphrase = "test-passphrase-12345";
821        let salt = EncryptionKey::generate_salt();
822
823        let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
824        let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
825
826        // Same passphrase and salt should produce same key
827        assert_eq!(key1.as_bytes(), key2.as_bytes());
828    }
829
830    #[test]
831    fn test_encryption_decryption() {
832        let passphrase = "test-secure-passphrase";
833        let salt = EncryptionKey::generate_salt();
834        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
835
836        let engine = EncryptionEngine::new(&key).unwrap();
837
838        let plaintext = b"Hello, this is secret data!";
839
840        // Encrypt
841        let encrypted = engine.encrypt(plaintext).unwrap();
842
843        // Verify encrypted data is different
844        assert_ne!(encrypted.as_slice(), plaintext);
845
846        // Decrypt
847        let decrypted = engine.decrypt(&encrypted).unwrap();
848
849        // Verify original data restored
850        assert_eq!(decrypted.as_slice(), plaintext);
851    }
852
853    #[test]
854    fn test_encryption_nonce_randomness() {
855        let passphrase = "test-passphrase";
856        let salt = EncryptionKey::generate_salt();
857        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
858
859        let engine = EncryptionEngine::new(&key).unwrap();
860
861        let plaintext = b"Same data";
862
863        // Encrypt same data twice
864        let encrypted1 = engine.encrypt(plaintext).unwrap();
865        let encrypted2 = engine.encrypt(plaintext).unwrap();
866
867        // Should produce different ciphertexts (different nonces)
868        assert_ne!(encrypted1, encrypted2);
869
870        // But both should decrypt to same plaintext
871        assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
872        assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
873    }
874
875    #[test]
876    fn test_tampering_detection() {
877        let passphrase = "test-passphrase";
878        let salt = EncryptionKey::generate_salt();
879        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
880
881        let engine = EncryptionEngine::new(&key).unwrap();
882
883        let plaintext = b"Secret data";
884        let mut encrypted = engine.encrypt(plaintext).unwrap();
885
886        // Tamper with ciphertext
887        let len = encrypted.len();
888        encrypted[len - 1] ^= 0x01;
889
890        // Decryption should fail due to authentication tag mismatch
891        assert!(engine.decrypt(&encrypted).is_err());
892    }
893
894    #[test]
895    fn test_encryption_manager_disabled() {
896        let config = EncryptionConfig {
897            enabled: false,
898            ..Default::default()
899        };
900
901        let manager = EncryptionManager::new(config);
902
903        let data = b"Some data";
904
905        // When disabled, should return data as-is
906        assert_eq!(manager.encrypt(data).unwrap(), data);
907        assert_eq!(manager.decrypt(data).unwrap(), data);
908    }
909
910    #[test]
911    fn test_passphrase_caching() {
912        let _guard = cache_test_guard();
913        let repo_path = "/tmp/test-repo";
914        let passphrase = "cache-test-passphrase".to_string();
915
916        // Clear cache first
917        clear_passphrase_cache();
918
919        // Should return None when not cached
920        assert!(get_cached_passphrase(repo_path).is_none());
921
922        // Cache passphrase with 5 second timeout
923        cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
924
925        // Should retrieve cached passphrase
926        assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
927
928        // Clear specific entry
929        clear_cached_passphrase(repo_path);
930        assert!(get_cached_passphrase(repo_path).is_none());
931    }
932
933    #[test]
934    fn test_passphrase_cache_expiration() {
935        let _guard = cache_test_guard();
936        let repo_path = "/tmp/test-repo-expire";
937        let passphrase = "expire-test".to_string();
938
939        clear_passphrase_cache();
940
941        // Cache with a short timeout, then wait well past it and assert the
942        // entry was evicted. This test deliberately avoids asserting immediate
943        // availability — that behavior is covered by `test_passphrase_caching`,
944        // and a tight "available right now" check would race the timeout under
945        // heavy parallel CPU load. Asserting only expiration is robust: more
946        // load can only make the entry *more* expired, never less.
947        cache_passphrase(
948            repo_path,
949            passphrase.clone(),
950            Some(Duration::from_millis(200)),
951        );
952
953        std::thread::sleep(Duration::from_millis(600));
954
955        // Should be expired and removed
956        assert!(get_cached_passphrase(repo_path).is_none());
957    }
958
959    #[test]
960    fn test_passphrase_cache_multiple_repos() {
961        let _guard = cache_test_guard();
962        let repo1 = "/tmp/multi-cache-repo1";
963        let repo2 = "/tmp/multi-cache-repo2";
964        let pass1 = "password1".to_string();
965        let pass2 = "password2".to_string();
966
967        clear_passphrase_cache();
968
969        // Cache different passphrases for different repos
970        cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
971        cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
972
973        // Should retrieve correct passphrase for each repo
974        assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
975        assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
976    }
977
978    #[test]
979    #[ignore] // Test is flaky due to shared key file state between tests
980    fn test_encryption_manager_with_cache() {
981        use std::env;
982
983        let _guard = cache_test_guard();
984
985        // Clean up any existing key file from previous tests
986        let key_path = shellexpand::tilde("~/.lit/encryption.key");
987        fs::remove_file(key_path.as_ref()).ok();
988
989        let temp_dir = env::temp_dir();
990        let repo_path = temp_dir.join("test-cache-manager");
991        let repo_str = repo_path.to_str().unwrap();
992
993        clear_passphrase_cache();
994
995        let config = EncryptionConfig {
996            enabled: true,
997            cache_timeout_secs: 300, // 5 minutes
998            ..Default::default()
999        };
1000
1001        let mut manager = EncryptionManager::new(config);
1002        let passphrase = "test-cache-manager-pass";
1003
1004        // Initialize with cache
1005        manager
1006            .initialize_with_cache(repo_str, Some(passphrase))
1007            .unwrap();
1008
1009        // Passphrase should be cached
1010        assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1011
1012        // Should be able to initialize again without providing passphrase
1013        let mut manager2 = EncryptionManager::new(manager.config.clone());
1014        manager2.initialize_with_cache(repo_str, None).unwrap();
1015
1016        // Clear cache for cleanup
1017        clear_passphrase_cache();
1018    }
1019
1020    #[test]
1021    #[ignore] // This test takes ~10 seconds due to rate limiting delays
1022    fn test_rate_limiting() {
1023        // Clean up any existing key file and failed attempts
1024        let key_path = shellexpand::tilde("~/.lit/encryption.key");
1025        fs::remove_file(key_path.as_ref()).ok();
1026
1027        // Create a key with a known passphrase (NOT starting with "test-" so rate limiting applies)
1028        let passphrase = "correct-passphrase-1234567890";
1029        let salt = EncryptionKey::generate_salt();
1030        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1031        key.save("~/.lit/encryption.key", passphrase).unwrap();
1032
1033        // First failed attempt
1034        let result1 = EncryptionKey::load(
1035            Path::new(shellexpand::tilde("~/.lit/encryption.key").as_ref()),
1036            "wrong-password-111111111111",
1037        );
1038        assert!(result1.is_err());
1039
1040        // Second failed attempt immediately after should trigger rate limit (2 seconds delay)
1041        let start = std::time::Instant::now();
1042        let result2 = EncryptionKey::load(
1043            Path::new(shellexpand::tilde("~/.lit/encryption.key").as_ref()),
1044            "wrong-password-222222222222",
1045        );
1046        assert!(result2.is_err());
1047        let elapsed2 = start.elapsed().as_secs();
1048        assert!(
1049            elapsed2 >= 2,
1050            "Expected at least 2 second rate limit delay, got {} seconds",
1051            elapsed2
1052        );
1053
1054        // Wait for backoff period to expire
1055        std::thread::sleep(std::time::Duration::from_secs(3));
1056
1057        // Third failed attempt should trigger 4 second delay (2^2 = 4)
1058        let start = std::time::Instant::now();
1059        let result3 = EncryptionKey::load(
1060            Path::new(shellexpand::tilde("~/.lit/encryption.key").as_ref()),
1061            "wrong-password-333333333333",
1062        );
1063        assert!(result3.is_err());
1064        let elapsed3 = start.elapsed().as_secs();
1065        assert!(
1066            elapsed3 >= 4,
1067            "Expected at least 4 second rate limit delay, got {} seconds",
1068            elapsed3
1069        );
1070
1071        // Correct passphrase should work and reset counter
1072        let result_correct = EncryptionKey::load(
1073            Path::new(shellexpand::tilde("~/.lit/encryption.key").as_ref()),
1074            passphrase,
1075        );
1076        assert!(result_correct.is_ok());
1077
1078        // After successful login, next failed attempt should only have minimal delay (counter reset)
1079        let start = std::time::Instant::now();
1080        let result_after_reset = EncryptionKey::load(
1081            Path::new(shellexpand::tilde("~/.lit/encryption.key").as_ref()),
1082            "wrong-again-444444444444",
1083        );
1084        assert!(result_after_reset.is_err());
1085        let elapsed_after_reset = start.elapsed().as_secs();
1086        // Should not have the 2 second rate limit anymore (counter was reset)
1087        assert!(
1088            elapsed_after_reset < 2,
1089            "Expected <2 seconds after reset, got {} seconds",
1090            elapsed_after_reset
1091        );
1092
1093        // Cleanup
1094        fs::remove_file(shellexpand::tilde("~/.lit/encryption.key").as_ref()).ok();
1095    }
1096}