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    /// Keys already derived in this process, so a command that opens several
49    /// stores pays PBKDF2 once rather than once per store. Memory-only.
50    static ref DERIVED_KEYS: Mutex<HashMap<String, std::sync::Arc<EncryptionKey>>> = Mutex::new(HashMap::new());
51}
52
53/// Tracks failed passphrase attempts for rate limiting
54struct FailedAttemptTracker {
55    count: u32,
56    last_attempt: SystemTime,
57    lockout_until: Option<SystemTime>,
58}
59
60/// AES-GCM nonce size in bytes (96 bits recommended)
61const NONCE_SIZE: usize = 12;
62
63/// PBKDF2 iteration count for FIPS 140-3 compliance
64/// NIST SP 800-132 (2010) recommends minimum 10,000
65/// NIST SP 800-63B (2024) recommends minimum 210,000
66/// We use 600,000 for enhanced security against modern GPU attacks
67/// This provides ~2.85x the current NIST recommendation
68const PBKDF2_ITERATIONS: u32 = 600_000;
69
70/// Salt size for PBKDF2 (16 bytes = 128 bits)
71/// Meets NIST SP 800-132 requirement for >= 128 bits
72const SALT_SIZE: usize = 16;
73
74/// Encrypted data header version
75const ENCRYPTION_VERSION: u8 = 1;
76
77/// Encryption configuration
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EncryptionConfig {
80    /// Enable encryption for repository data
81    pub enabled: bool,
82    /// Path to encrypted key file
83    pub key_file: String,
84    /// FIPS 140-3 mode (strict algorithm compliance)
85    pub fips_mode: bool,
86    /// Passphrase cache timeout in seconds (0 to disable caching)
87    #[serde(default = "default_cache_timeout")]
88    pub cache_timeout_secs: u64,
89}
90
91fn default_cache_timeout() -> u64 {
92    300 // 5 minutes
93}
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    /// Load configuration from repository
108    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    /// Save configuration to repository
122    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
133/// Identify a derived key by the file it came from and the passphrase that
134/// unlocked it, without keeping the passphrase around.
135///
136/// Both parts matter: the file alone would hand back the wrong key after a
137/// `rotate-key` within one process.
138fn derived_key_id(key_file: &str, passphrase: &str) -> String {
139    use sha3::{Digest, Sha3_256};
140    let mut hasher = Sha3_256::new();
141    hasher.update(key_file.as_bytes());
142    hasher.update([0u8]); // keep the two fields from running together
143    hasher.update(passphrase.as_bytes());
144    hex::encode(hasher.finalize())
145}
146
147/// A key already derived in this process, if there is one.
148fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
149    DERIVED_KEYS.lock().ok()?.get(id).cloned()
150}
151
152/// Remember a successfully derived key for the life of the process.
153fn remember_derived_key(id: String, key: std::sync::Arc<EncryptionKey>) {
154    if let Ok(mut keys) = DERIVED_KEYS.lock() {
155        keys.insert(id, key);
156    }
157}
158
159/// Check rate limit for passphrase attempts
160/// Returns Ok(()) if attempt is allowed, Err with message if rate limited
161fn check_rate_limit(repo_path: &str) -> Result<(), String> {
162    let mut attempts = FAILED_ATTEMPTS
163        .lock()
164        .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
165    let tracker = attempts
166        .entry(repo_path.to_string())
167        .or_insert_with(|| FailedAttemptTracker {
168            count: 0,
169            last_attempt: SystemTime::now(),
170            lockout_until: None,
171        });
172
173    // Check if currently locked out
174    if let Some(lockout) = tracker.lockout_until {
175        if SystemTime::now() < lockout {
176            let remaining = lockout
177                .duration_since(SystemTime::now())
178                .unwrap_or(Duration::from_secs(0));
179            return Err(format!(
180                "Too many failed attempts. Please wait {} seconds before trying again.",
181                remaining.as_secs()
182            ));
183        }
184        // Lockout expired, reset counter
185        tracker.lockout_until = None;
186        tracker.count = 0;
187    }
188
189    // Apply exponential backoff: 2^n seconds (max 32 seconds for n=5)
190    if tracker.count > 0 {
191        let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
192        if let Ok(elapsed) = tracker.last_attempt.elapsed() {
193            if elapsed < delay {
194                let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
195                return Err(format!(
196                    "Please wait {} seconds between passphrase attempts.",
197                    remaining
198                ));
199            }
200        }
201    }
202
203    Ok(())
204}
205
206/// Record a failed passphrase attempt
207fn record_failed_attempt(repo_path: &str) {
208    let Ok(mut attempts) = FAILED_ATTEMPTS.lock() else {
209        return;
210    };
211    let tracker = attempts
212        .entry(repo_path.to_string())
213        .or_insert_with(|| FailedAttemptTracker {
214            count: 0,
215            last_attempt: SystemTime::now(),
216            lockout_until: None,
217        });
218
219    tracker.count += 1;
220    tracker.last_attempt = SystemTime::now();
221
222    // Lock out for 5 minutes after 5 failed attempts
223    if tracker.count >= 5 {
224        tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
225        eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
226    }
227}
228
229/// Clear failed attempt counter (called on successful authentication)
230fn clear_failed_attempts(repo_path: &str) {
231    if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
232        attempts.remove(repo_path);
233    }
234}
235
236/// Secure encryption key with automatic zeroization
237#[derive(ZeroizeOnDrop)]
238#[allow(unused_assignments)]
239pub struct EncryptionKey {
240    key_bytes: [u8; KEY_SIZE],
241    /// Salt used to derive this key (needed for saving)
242    #[zeroize(skip)]
243    salt: [u8; SALT_SIZE],
244}
245
246impl EncryptionKey {
247    /// Derive key from passphrase using PBKDF2-HMAC-SHA512
248    pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
249        // SECURITY: Test bypass only available in test builds (FINDING-001)
250        #[cfg(not(test))]
251        validate_passphrase_strength(passphrase)?;
252        #[cfg(test)]
253        if !passphrase.starts_with("test-") {
254            validate_passphrase_strength(passphrase)?;
255        }
256
257        if salt.len() != SALT_SIZE {
258            return Err(format!(
259                "Invalid salt size: expected {}, got {}",
260                SALT_SIZE,
261                salt.len()
262            ));
263        }
264
265        let mut key_bytes = [0u8; KEY_SIZE];
266        pbkdf2_hmac::<Sha512>(
267            passphrase.as_bytes(),
268            salt,
269            PBKDF2_ITERATIONS,
270            &mut key_bytes,
271        );
272
273        let mut salt_array = [0u8; SALT_SIZE];
274        salt_array.copy_from_slice(salt);
275
276        Ok(EncryptionKey {
277            key_bytes,
278            salt: salt_array,
279        })
280    }
281
282    /// Generate a random salt for key derivation
283    pub fn generate_salt() -> [u8; SALT_SIZE] {
284        use aes_gcm::aead::rand_core::RngCore;
285        let mut salt = [0u8; SALT_SIZE];
286        OsRng.fill_bytes(&mut salt);
287        salt
288    }
289
290    /// Load key from encrypted key file
291    /// SECURITY: Verifies passphrase using stored hash (constant-time comparison)
292    /// SECURITY: Rate limiting prevents brute force attacks
293    pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
294        // SECURITY: Rate limit check — test bypass only in test builds (FINDING-001)
295        let key_file_str = key_file.to_string_lossy().to_string();
296        #[cfg(not(test))]
297        check_rate_limit(&key_file_str)?;
298        #[cfg(test)]
299        if !passphrase.starts_with("test-") {
300            check_rate_limit(&key_file_str)?;
301        }
302
303        if !key_file.exists() {
304            return Err(
305                "Encryption key file not found. Initialize repository with encryption first."
306                    .to_string(),
307            );
308        }
309
310        let encrypted_data =
311            fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
312
313        if encrypted_data.len() < SALT_SIZE + 1 {
314            return Err("Invalid key file format (too short)".to_string());
315        }
316
317        // Extract components
318        let salt = &encrypted_data[0..SALT_SIZE];
319        let version = encrypted_data[SALT_SIZE];
320
321        if version != ENCRYPTION_VERSION {
322            return Err(format!("Unsupported key file version: {}", version));
323        }
324
325        // Check if old format (no verification hash) or new format
326        if encrypted_data.len() == SALT_SIZE + 1 {
327            // Old format - just derive key (backward compatibility)
328            let key = Self::from_passphrase(passphrase, salt)?;
329            // Clear failed attempts on successful load
330            clear_failed_attempts(&key_file_str);
331            return Ok(key);
332        }
333
334        if encrypted_data.len() < SALT_SIZE + 1 + 32 {
335            return Err("Invalid key file format (unexpected size)".to_string());
336        }
337
338        let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
339
340        // Derive key from passphrase
341        let key = Self::from_passphrase(passphrase, salt)?;
342
343        // Verify passphrase using constant-time comparison
344        use sha2::{Digest, Sha256};
345        let mut hasher = Sha256::new();
346        hasher.update(b"lit-passphrase-verification-v1");
347        hasher.update(&key.key_bytes);
348        let verification_hash = hasher.finalize();
349
350        // Constant-time comparison to prevent timing attacks
351        use subtle::ConstantTimeEq;
352        if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
353            // SECURITY: Record failed attempt — test bypass only in test builds (FINDING-001)
354            #[cfg(not(test))]
355            record_failed_attempt(&key_file_str);
356            #[cfg(test)]
357            if !passphrase.starts_with("test-") {
358                record_failed_attempt(&key_file_str);
359            }
360            // Add delay to prevent timing-based passphrase enumeration
361            std::thread::sleep(std::time::Duration::from_millis(100));
362            return Err("Invalid passphrase".to_string());
363        }
364
365        // Clear failed attempts on successful authentication
366        clear_failed_attempts(&key_file_str);
367        Ok(key)
368    }
369
370    /// Save key to encrypted key file
371    /// SECURITY: Uses atomic write (temp file + rename) to prevent corruption
372    /// on crash or power loss. Includes verification hash for passphrase validation.
373    pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
374        let expanded = shellexpand::tilde(key_file_str);
375        let key_file = Path::new(expanded.as_ref());
376
377        // Generate verification hash using current key
378        use sha2::{Digest, Sha256};
379        let mut hasher = Sha256::new();
380        hasher.update(b"lit-passphrase-verification-v1");
381        hasher.update(self.key_bytes);
382        let verification_hash = hasher.finalize();
383
384        // Create key file directory if needed
385        if let Some(parent) = key_file.parent() {
386            fs::create_dir_all(parent)
387                .map_err(|e| format!("Failed to create key directory: {}", e))?;
388        }
389
390        // Store: salt + version + verification_hash
391        let mut data = Vec::new();
392        data.extend_from_slice(&self.salt);
393        data.push(ENCRYPTION_VERSION);
394        data.extend_from_slice(&verification_hash);
395
396        // Atomic write: write to temp file then rename to prevent corruption
397        let temp_file = key_file.with_extension("tmp");
398        fs::write(&temp_file, &data)
399            .map_err(|e| format!("Failed to write temp key file: {}", e))?;
400        fs::rename(&temp_file, key_file)
401            .map_err(|e| format!("Failed to rename key file: {}", e))?;
402
403        Ok(())
404    }
405
406    /// Get raw key bytes (used internally)
407    fn as_bytes(&self) -> &[u8; KEY_SIZE] {
408        &self.key_bytes
409    }
410}
411
412/// Maximum encryptions per key (NIST SP 800-38D recommendation)
413/// Never exceed 2^32 encryptions with same key to prevent nonce reuse
414const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
415
416/// Encryption engine using AES-256-GCM
417/// SECURITY: Uses atomic counter to guarantee nonce uniqueness
418pub struct EncryptionEngine {
419    cipher: Aes256Gcm,
420    /// Atomic counter for nonce generation (ensures uniqueness)
421    nonce_counter: AtomicU64,
422}
423
424impl EncryptionEngine {
425    /// Create new encryption engine with key
426    pub fn new(key: &EncryptionKey) -> Result<Self, String> {
427        let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
428            .map_err(|e| format!("Failed to create cipher: {}", e))?;
429
430        Ok(EncryptionEngine {
431            cipher,
432            nonce_counter: AtomicU64::new(0),
433        })
434    }
435
436    /// Encrypt data with authenticated encryption (AES-256-GCM)
437    ///
438    /// Format: [version: 1 byte][nonce: 12 bytes][ciphertext + auth tag]
439    /// SECURITY: Uses counter-based nonce to guarantee uniqueness
440    #[allow(deprecated)]
441    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
442        // Invocation limit for a random nonce (NIST SP 800-38D §8.3).
443        //
444        // The counter is per engine, so this bounds one process rather than the
445        // lifetime of the key; a durable count would need state that survives
446        // the command. It is a backstop, not the guarantee — `rotate-key`
447        // remains the real control.
448        let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
449        if count >= MAX_ENCRYPTIONS_PER_KEY {
450            return Err(format!(
451                "Encryption limit exceeded ({} operations). Key rotation required for security.",
452                MAX_ENCRYPTIONS_PER_KEY
453            ));
454        }
455
456        // Nonce: 96 random bits, the RBG-based construction of NIST SP 800-38D
457        // §8.2.2, which is why the invocation limit above is 2^32.
458        //
459        // This was previously a counter in the top 8 bytes with 4 random bytes
460        // after it, described as guaranteeing uniqueness. It did not: the
461        // counter lives in the engine and restarts at zero for every engine —
462        // every process, and every store or index opened within one — so the
463        // first encryption after each start always reused counter 0 and only
464        // those 4 random bytes stood between two nonces. Colliding 32 bits is
465        // a birthday problem over roughly 65,000 encryptions, and a repeated
466        // nonce under one AES-GCM key does not merely leak the XOR of the two
467        // plaintexts, it exposes the GHASH key and with it forgery.
468        //
469        // 96 random bits put the same collision out of reach, and the nonce is
470        // stored alongside the ciphertext, so data written under the old scheme
471        // still decrypts.
472        use aes_gcm::aead::rand_core::RngCore;
473        let mut nonce_bytes = [0u8; NONCE_SIZE];
474        OsRng.fill_bytes(&mut nonce_bytes);
475        let nonce = Nonce::from_slice(&nonce_bytes);
476
477        // Encrypt with authenticated encryption
478        let ciphertext = self
479            .cipher
480            .encrypt(nonce, plaintext)
481            .map_err(|e| format!("Encryption failed: {}", e))?;
482
483        // Build output: version + nonce + ciphertext
484        let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
485        output.push(ENCRYPTION_VERSION);
486        output.extend_from_slice(&nonce_bytes);
487        output.extend_from_slice(&ciphertext);
488
489        Ok(output)
490    }
491
492    /// Decrypt data with authentication verification
493    #[allow(deprecated)]
494    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
495        if encrypted.len() < 1 + NONCE_SIZE {
496            return Err("Invalid encrypted data: too short".to_string());
497        }
498
499        // Extract version
500        let version = encrypted[0];
501        if version != ENCRYPTION_VERSION {
502            return Err(format!("Unsupported encryption version: {}", version));
503        }
504
505        // Extract nonce
506        let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
507        let nonce = Nonce::from_slice(nonce_bytes);
508
509        // Extract ciphertext
510        let ciphertext = &encrypted[1 + NONCE_SIZE..];
511
512        // Decrypt and verify authentication tag
513        let plaintext = self
514            .cipher
515            .decrypt(nonce, ciphertext)
516            .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
517
518        Ok(plaintext)
519    }
520}
521
522/// Passphrase cache operations
523impl CachedPassphrase {
524    /// Check if cached passphrase is still valid
525    fn is_valid(&self) -> bool {
526        SystemTime::now() < self.expires_at
527    }
528}
529
530/// Store passphrase in cache with timeout
531/// SECURITY: Passphrase stored in Zeroizing wrapper for automatic memory clearing
532pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
533    let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
534    let expires_at = SystemTime::now() + timeout;
535
536    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
537        cache.insert(
538            repo_path.to_string(),
539            CachedPassphrase {
540                passphrase: Zeroizing::new(passphrase),
541                expires_at,
542            },
543        );
544    }
545}
546
547/// Retrieve cached passphrase if valid
548/// SECURITY: Returns clone of Zeroizing-wrapped passphrase
549pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
550    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
551        if let Some(entry) = cache.get(repo_path) {
552            if entry.is_valid() {
553                return Some(entry.passphrase.clone());
554            } else {
555                // Remove expired entry (passphrase auto-zeroized on drop)
556                cache.remove(repo_path);
557            }
558        }
559    }
560    None
561}
562
563/// Clear all cached passphrases
564pub fn clear_passphrase_cache() {
565    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
566        cache.clear();
567    }
568}
569
570/// Clear cached passphrase for specific repository
571pub fn clear_cached_passphrase(repo_path: &str) {
572    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
573        cache.remove(repo_path);
574    }
575}
576
577/// Get passphrase from non-interactive sources
578///
579/// Priority: LIT_PASSPHRASE env var > LIT_PASSPHRASE_FILE env var > cache
580/// Returns None if no non-interactive source is available.
581/// SECURITY: Returns Zeroizing<String> to ensure passphrase is cleared from memory.
582fn get_passphrase_non_interactive(
583    repo_path: &str,
584    config: &EncryptionConfig,
585) -> Option<Zeroizing<String>> {
586    // 1. Check LIT_PASSPHRASE env var
587    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
588        if !pass.is_empty() {
589            return Some(Zeroizing::new(pass));
590        }
591    }
592
593    // 2. Check LIT_PASSPHRASE_FILE env var
594    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
595        if let Ok(pass) = std::fs::read_to_string(&path) {
596            let pass = pass
597                .trim_end_matches('\n')
598                .trim_end_matches('\r')
599                .to_string();
600            if !pass.is_empty() {
601                return Some(Zeroizing::new(pass));
602            }
603        }
604    }
605
606    // 3. Check cache
607    if config.cache_timeout_secs > 0 {
608        if let Some(cached) = get_cached_passphrase(repo_path) {
609            return Some(cached);
610        }
611    }
612
613    None
614}
615
616/// Prompt user for passphrase securely via CLI
617///
618/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > cache > interactive prompt.
619/// In non-interactive mode (default for agents), returns error if no passphrase
620/// is available from env/file/cache.
621pub fn prompt_for_passphrase(
622    repo_path: &str,
623    config: &EncryptionConfig,
624    prompt_text: &str,
625) -> Result<Zeroizing<String>, String> {
626    // Try non-interactive sources first
627    if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
628        return Ok(pass);
629    }
630
631    // Agent safety: never block on an interactive prompt when there is no TTY
632    // (the default for agents, pipes, and CI). Fail fast with remediation.
633    if !std::io::stdin().is_terminal() {
634        return Err(
635            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
636             LIT_PASSPHRASE_FILE"
637                .to_string(),
638        );
639    }
640
641    // Fall back to interactive prompt
642    rpassword::prompt_password(prompt_text)
643        .map(Zeroizing::new)
644        .map_err(|e| format!("Failed to read passphrase: {}", e))
645}
646
647/// Minimum passphrase length (NIST SP 800-63B recommendation for high security)
648const MIN_PASSPHRASE_LENGTH: usize = 16;
649
650/// Validate passphrase strength
651///
652/// Requirements:
653/// - Minimum 16 characters (NIST SP 800-63B)
654/// - At least 3 of: uppercase, lowercase, digits, special characters
655fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
656    // SECURITY: Test bypass only available in test builds (FINDING-001)
657    #[cfg(test)]
658    if passphrase.starts_with("test-") {
659        return Ok(());
660    }
661
662    if passphrase.len() < MIN_PASSPHRASE_LENGTH {
663        return Err(format!(
664            "Passphrase must be at least {} characters (recommended: 20+)",
665            MIN_PASSPHRASE_LENGTH
666        ));
667    }
668
669    // Check complexity
670    let has_upper = passphrase.chars().any(|c| c.is_uppercase());
671    let has_lower = passphrase.chars().any(|c| c.is_lowercase());
672    let has_digit = passphrase.chars().any(|c| c.is_numeric());
673    let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
674
675    let complexity_count = [has_upper, has_lower, has_digit, has_special]
676        .iter()
677        .filter(|&&x| x)
678        .count();
679
680    if complexity_count < 3 {
681        return Err(
682            "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
683                .to_string(),
684        );
685    }
686
687    Ok(())
688}
689
690/// Prompt for passphrase confirmation (for new passphrases)
691///
692/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > interactive prompt (with confirmation).
693pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
694    // Check LIT_PASSPHRASE env var
695    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
696        if !pass.is_empty() {
697            validate_passphrase_strength(&pass)?;
698            return Ok(Zeroizing::new(pass));
699        }
700    }
701
702    // Check LIT_PASSPHRASE_FILE env var
703    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
704        if let Ok(pass) = std::fs::read_to_string(&path) {
705            let pass = pass
706                .trim_end_matches('\n')
707                .trim_end_matches('\r')
708                .to_string();
709            if !pass.is_empty() {
710                validate_passphrase_strength(&pass)?;
711                return Ok(Zeroizing::new(pass));
712            }
713        }
714    }
715
716    // Agent safety: never block on an interactive prompt when there is no TTY.
717    if !std::io::stdin().is_terminal() {
718        return Err(
719            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
720             LIT_PASSPHRASE_FILE"
721                .to_string(),
722        );
723    }
724
725    // Interactive prompt with confirmation
726    let pass1 = rpassword::prompt_password(prompt_text)
727        .map_err(|e| format!("Failed to read passphrase: {}", e))?;
728
729    let pass2 = rpassword::prompt_password("Confirm passphrase: ")
730        .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
731
732    if pass1 != pass2 {
733        return Err("Passphrases do not match".to_string());
734    }
735
736    validate_passphrase_strength(&pass1)?;
737
738    Ok(Zeroizing::new(pass1))
739}
740
741/// Encryption manager for repository
742pub struct EncryptionManager {
743    config: EncryptionConfig,
744    engine: Option<EncryptionEngine>,
745    repo_path: Option<String>,
746}
747
748impl EncryptionManager {
749    /// Create new encryption manager
750    pub fn new(config: EncryptionConfig) -> Self {
751        EncryptionManager {
752            config,
753            engine: None,
754            repo_path: None,
755        }
756    }
757
758    /// Build a manager, initializing it from a non-interactive passphrase
759    /// source when encryption is enabled and one is available.
760    ///
761    /// Every command builds its object store through `ObjectStore::new`, which
762    /// returns `Self` rather than a `Result` and must not prompt — Lit is
763    /// zero-prompt by design. So the passphrase comes from `LIT_PASSPHRASE`,
764    /// `LIT_PASSPHRASE_FILE` or the cache, and nothing else.
765    ///
766    /// With encryption enabled and no source available the manager stays
767    /// uninitialized on purpose: the first encrypt or decrypt then reports
768    /// that plainly, which is a better failure than a constructor that cannot
769    /// explain itself.
770    pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
771        let mut manager = EncryptionManager::new(config);
772        if !manager.config.enabled {
773            return manager;
774        }
775
776        let repo = repo_path.to_string_lossy().to_string();
777        let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
778            return manager;
779        };
780
781        manager.repo_path = Some(repo.clone());
782        if let Err(e) = manager.initialize(&passphrase) {
783            eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
784            return manager;
785        }
786
787        if manager.config.cache_timeout_secs > 0 {
788            let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
789            cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
790        }
791
792        manager
793    }
794
795    /// Initialize encryption with passphrase
796    pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
797        if !self.config.enabled {
798            return Ok(());
799        }
800
801        let expanded = shellexpand::tilde(&self.config.key_file);
802        let key_file = Path::new(expanded.as_ref());
803
804        // A command opens several stores — the object store, the index, and the
805        // pack reader behind them — and each one lands here. Deriving the key
806        // every time means paying PBKDF2's 600,000 iterations several times
807        // over for a single `lit status`. Reuse a key already derived in this
808        // process for the same file and passphrase.
809        //
810        // The cache is memory-only and dies with the process, so it widens no
811        // window that holding the key for the length of one command already
812        // opens. Only successful derivations are stored, so a wrong passphrase
813        // still goes the long way round and still meets the rate limiter.
814        let cache_id = derived_key_id(expanded.as_ref(), passphrase);
815        if let Some(key) = cached_derived_key(&cache_id) {
816            self.engine = Some(EncryptionEngine::new(&key)?);
817            return Ok(());
818        }
819
820        // Load or create encryption key
821        let key = if key_file.exists() {
822            EncryptionKey::load(key_file, passphrase)?
823        } else {
824            let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
825            key.save(&self.config.key_file, passphrase)?;
826            key
827        };
828
829        let key = std::sync::Arc::new(key);
830        remember_derived_key(cache_id, std::sync::Arc::clone(&key));
831
832        // Create encryption engine
833        self.engine = Some(EncryptionEngine::new(&key)?);
834
835        Ok(())
836    }
837
838    /// Initialize encryption with passphrase caching support
839    pub fn initialize_with_cache(
840        &mut self,
841        repo_path: &str,
842        passphrase: Option<&str>,
843    ) -> Result<(), String> {
844        if !self.config.enabled {
845            return Ok(());
846        }
847
848        self.repo_path = Some(repo_path.to_string());
849
850        // Try to get cached passphrase first
851        let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
852            Zeroizing::new(pass.to_string())
853        } else if let Some(cached) = get_cached_passphrase(repo_path) {
854            cached
855        } else {
856            return Err("No passphrase provided and no valid cached passphrase found".to_string());
857        };
858
859        // Initialize encryption
860        self.initialize(&actual_passphrase)?;
861
862        // Cache the passphrase if caching is enabled
863        if self.config.cache_timeout_secs > 0 {
864            let timeout = Duration::from_secs(self.config.cache_timeout_secs);
865            cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
866        }
867
868        Ok(())
869    }
870
871    /// Encrypt data if encryption is enabled
872    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
873        if !self.config.enabled {
874            return Ok(plaintext.to_vec());
875        }
876
877        match &self.engine {
878            Some(engine) => engine.encrypt(plaintext),
879            None => Err(
880                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
881            ),
882        }
883    }
884
885    /// Decrypt data if encryption is enabled
886    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
887        if !self.config.enabled {
888            return Ok(encrypted.to_vec());
889        }
890
891        match &self.engine {
892            Some(engine) => {
893                // Data written before encryption was switched on carries no
894                // header of ours, so it fails here with a version number taken
895                // from whatever byte happened to be first — 123 for the `{` of
896                // the plaintext index, which explains nothing. Encryption
897                // cannot be turned on for a repository that already has
898                // content, and this is where a user finds that out.
899                if encrypted
900                    .first()
901                    .is_some_and(|version| *version != ENCRYPTION_VERSION)
902                {
903                    return Err(
904                        "This data has no Lit encryption header. Encryption cannot be \
905                         enabled for a repository that already contains unencrypted \
906                         commits — start a new encrypted repository and import into it."
907                            .to_string(),
908                    );
909                }
910                engine.decrypt(encrypted)
911            }
912            None => Err(
913                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
914            ),
915        }
916    }
917
918    /// Check if encryption is enabled
919    pub fn is_enabled(&self) -> bool {
920        self.config.enabled
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    /// Serializes tests that mutate the process-global passphrase cache.
929    ///
930    /// Several tests call [`clear_passphrase_cache`], which wipes every entry;
931    /// running them in parallel lets one test clear another's freshly-cached
932    /// entry, producing spurious failures. Holding this lock makes those tests
933    /// mutually exclusive. Poisoning is recovered from since a panic in one
934    /// test must not cascade into the others.
935    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
936
937    fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
938        CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
939    }
940
941    /// A key-file path belonging to a single test.
942    ///
943    /// Tests that exercise `EncryptionKey::save`/`load` write a real file, and
944    /// the rate-limiter keys its failed-attempt tracker off that path. Pointing
945    /// them at `~/.lit/encryption.key` therefore made them collide with one
946    /// another — and, since they delete the file to start clean, destroyed the
947    /// operator's real key on any run that included them. A per-test path in
948    /// the temp directory isolates the file and the tracker together.
949    fn test_key_path(label: &str) -> std::path::PathBuf {
950        static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
951        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
952        let path = std::env::temp_dir().join(format!(
953            "lit_enc_test_{}_{}_{}.key",
954            std::process::id(),
955            label,
956            n
957        ));
958        let _ = fs::remove_file(&path);
959        path
960    }
961
962    #[test]
963    fn test_key_derivation() {
964        let passphrase = "test-passphrase-12345";
965        let salt = EncryptionKey::generate_salt();
966
967        let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
968        let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
969
970        // Same passphrase and salt should produce same key
971        assert_eq!(key1.as_bytes(), key2.as_bytes());
972    }
973
974    #[test]
975    fn test_encryption_decryption() {
976        let passphrase = "test-secure-passphrase";
977        let salt = EncryptionKey::generate_salt();
978        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
979
980        let engine = EncryptionEngine::new(&key).unwrap();
981
982        let plaintext = b"Hello, this is secret data!";
983
984        // Encrypt
985        let encrypted = engine.encrypt(plaintext).unwrap();
986
987        // Verify encrypted data is different
988        assert_ne!(encrypted.as_slice(), plaintext);
989
990        // Decrypt
991        let decrypted = engine.decrypt(&encrypted).unwrap();
992
993        // Verify original data restored
994        assert_eq!(decrypted.as_slice(), plaintext);
995    }
996
997    #[test]
998    fn test_encryption_nonce_randomness() {
999        let passphrase = "test-passphrase";
1000        let salt = EncryptionKey::generate_salt();
1001        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1002
1003        let engine = EncryptionEngine::new(&key).unwrap();
1004
1005        let plaintext = b"Same data";
1006
1007        // Encrypt same data twice
1008        let encrypted1 = engine.encrypt(plaintext).unwrap();
1009        let encrypted2 = engine.encrypt(plaintext).unwrap();
1010
1011        // Should produce different ciphertexts (different nonces)
1012        assert_ne!(encrypted1, encrypted2);
1013
1014        // But both should decrypt to same plaintext
1015        assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1016        assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1017    }
1018
1019    #[test]
1020    fn test_tampering_detection() {
1021        let passphrase = "test-passphrase";
1022        let salt = EncryptionKey::generate_salt();
1023        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1024
1025        let engine = EncryptionEngine::new(&key).unwrap();
1026
1027        let plaintext = b"Secret data";
1028        let mut encrypted = engine.encrypt(plaintext).unwrap();
1029
1030        // Tamper with ciphertext
1031        let len = encrypted.len();
1032        encrypted[len - 1] ^= 0x01;
1033
1034        // Decryption should fail due to authentication tag mismatch
1035        assert!(engine.decrypt(&encrypted).is_err());
1036    }
1037
1038    #[test]
1039    fn test_encryption_manager_disabled() {
1040        let config = EncryptionConfig {
1041            enabled: false,
1042            ..Default::default()
1043        };
1044
1045        let manager = EncryptionManager::new(config);
1046
1047        let data = b"Some data";
1048
1049        // When disabled, should return data as-is
1050        assert_eq!(manager.encrypt(data).unwrap(), data);
1051        assert_eq!(manager.decrypt(data).unwrap(), data);
1052    }
1053
1054    #[test]
1055    fn test_passphrase_caching() {
1056        let _guard = cache_test_guard();
1057        let repo_path = "/tmp/test-repo";
1058        let passphrase = "cache-test-passphrase".to_string();
1059
1060        // Clear cache first
1061        clear_passphrase_cache();
1062
1063        // Should return None when not cached
1064        assert!(get_cached_passphrase(repo_path).is_none());
1065
1066        // Cache passphrase with 5 second timeout
1067        cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1068
1069        // Should retrieve cached passphrase
1070        assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1071
1072        // Clear specific entry
1073        clear_cached_passphrase(repo_path);
1074        assert!(get_cached_passphrase(repo_path).is_none());
1075    }
1076
1077    #[test]
1078    fn test_passphrase_cache_expiration() {
1079        let _guard = cache_test_guard();
1080        let repo_path = "/tmp/test-repo-expire";
1081        let passphrase = "expire-test".to_string();
1082
1083        clear_passphrase_cache();
1084
1085        // Cache with a short timeout, then wait well past it and assert the
1086        // entry was evicted. This test deliberately avoids asserting immediate
1087        // availability — that behavior is covered by `test_passphrase_caching`,
1088        // and a tight "available right now" check would race the timeout under
1089        // heavy parallel CPU load. Asserting only expiration is robust: more
1090        // load can only make the entry *more* expired, never less.
1091        cache_passphrase(
1092            repo_path,
1093            passphrase.clone(),
1094            Some(Duration::from_millis(200)),
1095        );
1096
1097        std::thread::sleep(Duration::from_millis(600));
1098
1099        // Should be expired and removed
1100        assert!(get_cached_passphrase(repo_path).is_none());
1101    }
1102
1103    #[test]
1104    fn test_passphrase_cache_multiple_repos() {
1105        let _guard = cache_test_guard();
1106        let repo1 = "/tmp/multi-cache-repo1";
1107        let repo2 = "/tmp/multi-cache-repo2";
1108        let pass1 = "password1".to_string();
1109        let pass2 = "password2".to_string();
1110
1111        clear_passphrase_cache();
1112
1113        // Cache different passphrases for different repos
1114        cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1115        cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1116
1117        // Should retrieve correct passphrase for each repo
1118        assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1119        assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1120    }
1121
1122    #[test]
1123    fn test_encryption_manager_with_cache() {
1124        use std::env;
1125
1126        let _guard = cache_test_guard();
1127
1128        let key_file = test_key_path("manager_cache");
1129
1130        let temp_dir = env::temp_dir();
1131        let repo_path = temp_dir.join("test-cache-manager");
1132        let repo_str = repo_path.to_str().unwrap();
1133
1134        clear_passphrase_cache();
1135
1136        let config = EncryptionConfig {
1137            enabled: true,
1138            key_file: key_file.to_string_lossy().into_owned(),
1139            cache_timeout_secs: 300, // 5 minutes
1140            ..Default::default()
1141        };
1142
1143        let mut manager = EncryptionManager::new(config);
1144        let passphrase = "test-cache-manager-pass";
1145
1146        // Initialize with cache
1147        manager
1148            .initialize_with_cache(repo_str, Some(passphrase))
1149            .unwrap();
1150
1151        // Passphrase should be cached
1152        assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1153
1154        // Should be able to initialize again without providing passphrase
1155        let mut manager2 = EncryptionManager::new(manager.config.clone());
1156        manager2.initialize_with_cache(repo_str, None).unwrap();
1157
1158        // Clear cache for cleanup
1159        clear_passphrase_cache();
1160        let _ = fs::remove_file(&key_file);
1161    }
1162
1163    /// Exercises the brute-force throttle on `EncryptionKey::load`.
1164    ///
1165    /// Ignored for runtime, not correctness: each attempt that gets as far as
1166    /// verification runs PBKDF2 at 600k iterations, which costs seconds in an
1167    /// unoptimized build. Run it with `cargo test -- --ignored`.
1168    #[test]
1169    #[ignore]
1170    fn test_rate_limiting() {
1171        let key_file = test_key_path("rate_limiting");
1172        let key_file_str = key_file.to_string_lossy().into_owned();
1173
1174        // A passphrase that does NOT start with "test-", so the test-only
1175        // bypass in `load` leaves the rate-limit check in play.
1176        let passphrase = "correct-passphrase-1234567890";
1177        let salt = EncryptionKey::generate_salt();
1178        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1179        key.save(&key_file_str, passphrase).unwrap();
1180
1181        // A wrong passphrase is rejected on its merits, and counted.
1182        assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1183
1184        // The next attempt falls inside the backoff window, so the throttle
1185        // turns it away before any verification happens. The throttle refuses
1186        // rather than sleeping, so the caller is told how long to wait instead
1187        // of having a thread parked on its behalf.
1188        // `unwrap_err` is avoided throughout: it would require `EncryptionKey`
1189        // to be `Debug`, and that type holds live key material.
1190        let start = std::time::Instant::now();
1191        let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1192            .err()
1193            .expect("an attempt inside the backoff window must be refused");
1194        assert!(
1195            throttled.contains("wait"),
1196            "expected a rate-limit refusal, got: {}",
1197            throttled
1198        );
1199        assert!(
1200            start.elapsed() < Duration::from_secs(1),
1201            "the throttle should refuse immediately rather than block the caller"
1202        );
1203
1204        // Once the 2^1-second window passes, attempts are evaluated again — the
1205        // failure that comes back is about the passphrase, not the throttle.
1206        std::thread::sleep(Duration::from_millis(2_100));
1207        let correct = EncryptionKey::load(&key_file, passphrase);
1208        assert!(
1209            correct.is_ok(),
1210            "the correct passphrase should be accepted once the window passes: {:?}",
1211            correct.as_ref().err()
1212        );
1213
1214        // Success clears the counter, so the next wrong attempt is judged on
1215        // its merits rather than being thrown out by the throttle.
1216        let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1217            .err()
1218            .expect("a wrong passphrase must still fail");
1219        assert!(
1220            !after_reset.contains("wait"),
1221            "a successful load should reset the counter, got: {}",
1222            after_reset
1223        );
1224
1225        let _ = fs::remove_file(&key_file);
1226    }
1227
1228    /// Nonces must not repeat across freshly created engines.
1229    ///
1230    /// The old construction put an engine-local counter in the top 8 bytes of
1231    /// the nonce, so every new engine — every process, every store opened —
1232    /// started again at zero and the first encryption always carried the same
1233    /// 8 leading bytes. Only 4 random bytes separated two such nonces, and a
1234    /// repeated nonce under one AES-GCM key is catastrophic. Simulate a run of
1235    /// separate processes and require the nonces to be distinct.
1236    #[test]
1237    fn test_nonces_do_not_repeat_across_engines() {
1238        let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1239
1240        let mut nonces = std::collections::HashSet::new();
1241        let mut leading_zero_runs = 0;
1242
1243        for _ in 0..64 {
1244            // A fresh engine each time, as a new process would build.
1245            let engine = EncryptionEngine::new(&key).unwrap();
1246            let blob = engine.encrypt(b"same plaintext every time").unwrap();
1247            let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1248
1249            if nonce[..8] == [0u8; 8] {
1250                leading_zero_runs += 1;
1251            }
1252            assert!(
1253                nonces.insert(nonce),
1254                "a nonce repeated across engines, which breaks AES-GCM"
1255            );
1256        }
1257
1258        // Under the old scheme every one of these would have started 0x00 * 8.
1259        assert!(
1260            leading_zero_runs <= 1,
1261            "{} of 64 nonces began with eight zero bytes, which means the \
1262             counter is resetting rather than the nonce being random",
1263            leading_zero_runs
1264        );
1265    }
1266}