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/// Keep a file readable only by its owner.
134///
135/// Mode 0600 on Unix; on Windows a DACL granting the current user alone, which
136/// is what closes finding I-1 in docs/SECURITY_AUDIT.md. Both are real
137/// restrictions on reading, not just writing.
138pub(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/// Replace a file's DACL with one granting only the current user.
157///
158/// This is what closes finding I-1 in docs/SECURITY_AUDIT.md. The read-only
159/// attribute that stood here before stops writes and does nothing about reads,
160/// so any local account could read the file; Windows needs an explicit ACL.
161///
162/// The new DACL is marked protected, which detaches it from the parent
163/// directory's inherited entries — otherwise an inherited "Users: Read" would
164/// survive and the restriction would be for nothing.
165#[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    // Win32 wants a NUL-terminated wide string.
181    let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
182    wide.push(0);
183
184    unsafe {
185        // The SID of whoever is running this.
186        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        // One entry: this user, full control, not inherited by anything.
206        let access = EXPLICIT_ACCESS_W {
207            grfAccessPermissions: 0x001F_01FF, // FILE_ALL_ACCESS
208            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        // PROTECTED detaches the file from inherited entries; without it the
227        // parent directory's grants would remain in force.
228        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
252/// Let a file be replaced by a rename, undoing what `restrict_to_owner` set.
253///
254/// Only Windows needs this: it refuses to rename onto a read-only file, and
255/// the restriction applied on the previous save is exactly that. A missing
256/// file is fine — there is nothing to clear.
257fn 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            // Clippy warns because clearing read-only on Unix makes a file
265            // world-writable. This block is Windows-only, where the attribute
266            // is not a permission at all and clearing it is what allows the
267            // replacing rename.
268            #[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
281/// Identify a derived key by the file it came from and the passphrase that
282/// unlocked it, without keeping the passphrase around.
283///
284/// Both parts matter: the file alone would hand back the wrong key after a
285/// `rotate-key` within one process.
286fn 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]); // keep the two fields from running together
291    hasher.update(passphrase.as_bytes());
292    hex::encode(hasher.finalize())
293}
294
295/// A key already derived in this process, if there is one.
296fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
297    DERIVED_KEYS.lock().ok()?.get(id).cloned()
298}
299
300/// Remember a successfully derived key for the life of the process.
301fn 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
307/// Check rate limit for passphrase attempts
308/// Returns Ok(()) if attempt is allowed, Err with message if rate limited
309fn 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    // Check if currently locked out
322    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        // Lockout expired, reset counter
333        tracker.lockout_until = None;
334        tracker.count = 0;
335    }
336
337    // Apply exponential backoff: 2^n seconds (max 32 seconds for n=5)
338    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
354/// Record a failed passphrase attempt
355fn 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    // Lock out for 5 minutes after 5 failed attempts
371    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
377/// Clear failed attempt counter (called on successful authentication)
378fn clear_failed_attempts(repo_path: &str) {
379    if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
380        attempts.remove(repo_path);
381    }
382}
383
384/// Secure encryption key with automatic zeroization
385#[derive(ZeroizeOnDrop)]
386#[allow(unused_assignments)]
387pub struct EncryptionKey {
388    key_bytes: [u8; KEY_SIZE],
389    /// Salt used to derive this key (needed for saving)
390    #[zeroize(skip)]
391    salt: [u8; SALT_SIZE],
392}
393
394impl EncryptionKey {
395    /// Derive key from passphrase using PBKDF2-HMAC-SHA512
396    pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
397        // SECURITY: Test bypass only available in test builds (FINDING-001)
398        #[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    /// Generate a random salt for key derivation
431    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    /// Load key from encrypted key file
439    /// SECURITY: Verifies passphrase using stored hash (constant-time comparison)
440    /// SECURITY: Rate limiting prevents brute force attacks
441    pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
442        // SECURITY: Rate limit check — test bypass only in test builds (FINDING-001)
443        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        // Re-apply on load, not only at creation. The salt in this file is what
459        // an offline brute force of the passphrase needs, and a key file written
460        // before the restriction existed keeps its inherited ACL for the life of
461        // the installation otherwise — the one on this machine dates to March.
462        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        // Extract components
472        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        // Check if old format (no verification hash) or new format
480        if encrypted_data.len() == SALT_SIZE + 1 {
481            // Old format - just derive key (backward compatibility)
482            let key = Self::from_passphrase(passphrase, salt)?;
483            // Clear failed attempts on successful load
484            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        // Derive key from passphrase
495        let key = Self::from_passphrase(passphrase, salt)?;
496
497        // Verify passphrase using constant-time comparison
498        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        // Constant-time comparison to prevent timing attacks
505        use subtle::ConstantTimeEq;
506        if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
507            // SECURITY: Record failed attempt — test bypass only in test builds (FINDING-001)
508            #[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            // Add delay to prevent timing-based passphrase enumeration
515            std::thread::sleep(std::time::Duration::from_millis(100));
516            return Err("Invalid passphrase".to_string());
517        }
518
519        // Clear failed attempts on successful authentication
520        clear_failed_attempts(&key_file_str);
521        Ok(key)
522    }
523
524    /// Save key to encrypted key file
525    /// SECURITY: Uses atomic write (temp file + rename) to prevent corruption
526    /// on crash or power loss. Includes verification hash for passphrase validation.
527    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        // Generate verification hash using current key
532        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        // Create key file directory if needed
539        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        // Store: salt + version + verification_hash
545        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        // Atomic write: write to temp file then rename to prevent corruption
551        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 before the file takes its real name, so it is never briefly
556        // readable under the path an attacker would watch.
557        //
558        // No key material is stored here — the key is derived from the
559        // passphrase and this salt — but the verification hash lets anyone
560        // holding the file test passphrase guesses offline, without needing the
561        // repository at all. That is worth keeping to the owner.
562        restrict_to_owner(&temp_file)?;
563
564        // Windows refuses to rename onto a read-only file, and the file being
565        // replaced is one this function marked read-only last time. Clearing
566        // the attribute first is what lets `rotate-key` save a second time; a
567        // test covers it, because the failure only appears on the second save.
568        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    /// Get raw key bytes (used internally)
577    fn as_bytes(&self) -> &[u8; KEY_SIZE] {
578        &self.key_bytes
579    }
580}
581
582/// Maximum encryptions per key (NIST SP 800-38D recommendation)
583/// Never exceed 2^32 encryptions with same key to prevent nonce reuse
584const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
585
586/// Encryption engine using AES-256-GCM
587/// SECURITY: Uses atomic counter to guarantee nonce uniqueness
588pub struct EncryptionEngine {
589    cipher: Aes256Gcm,
590    /// Atomic counter for nonce generation (ensures uniqueness)
591    nonce_counter: AtomicU64,
592}
593
594impl EncryptionEngine {
595    /// Create new encryption engine with key
596    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    /// Encrypt data with authenticated encryption (AES-256-GCM)
607    ///
608    /// Format: [version: 1 byte][nonce: 12 bytes][ciphertext + auth tag]
609    /// SECURITY: Uses counter-based nonce to guarantee uniqueness
610    #[allow(deprecated)]
611    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
612        // Invocation limit for a random nonce (NIST SP 800-38D §8.3).
613        //
614        // The counter is per engine, so this bounds one process rather than the
615        // lifetime of the key; a durable count would need state that survives
616        // the command. It is a backstop, not the guarantee — `rotate-key`
617        // remains the real control.
618        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        // Nonce: 96 random bits, the RBG-based construction of NIST SP 800-38D
627        // §8.2.2, which is why the invocation limit above is 2^32.
628        //
629        // This was previously a counter in the top 8 bytes with 4 random bytes
630        // after it, described as guaranteeing uniqueness. It did not: the
631        // counter lives in the engine and restarts at zero for every engine —
632        // every process, and every store or index opened within one — so the
633        // first encryption after each start always reused counter 0 and only
634        // those 4 random bytes stood between two nonces. Colliding 32 bits is
635        // a birthday problem over roughly 65,000 encryptions, and a repeated
636        // nonce under one AES-GCM key does not merely leak the XOR of the two
637        // plaintexts, it exposes the GHASH key and with it forgery.
638        //
639        // 96 random bits put the same collision out of reach, and the nonce is
640        // stored alongside the ciphertext, so data written under the old scheme
641        // still decrypts.
642        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        // Encrypt with authenticated encryption
648        let ciphertext = self
649            .cipher
650            .encrypt(nonce, plaintext)
651            .map_err(|e| format!("Encryption failed: {}", e))?;
652
653        // Build output: version + nonce + ciphertext
654        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    /// Decrypt data with authentication verification
663    #[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        // Extract version
670        let version = encrypted[0];
671        if version != ENCRYPTION_VERSION {
672            return Err(format!("Unsupported encryption version: {}", version));
673        }
674
675        // Extract nonce
676        let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
677        let nonce = Nonce::from_slice(nonce_bytes);
678
679        // Extract ciphertext
680        let ciphertext = &encrypted[1 + NONCE_SIZE..];
681
682        // Decrypt and verify authentication tag
683        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
692/// Passphrase cache operations
693impl CachedPassphrase {
694    /// Check if cached passphrase is still valid
695    fn is_valid(&self) -> bool {
696        SystemTime::now() < self.expires_at
697    }
698}
699
700/// Store passphrase in cache with timeout
701/// SECURITY: Passphrase stored in Zeroizing wrapper for automatic memory clearing
702pub 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
717/// Retrieve cached passphrase if valid
718/// SECURITY: Returns clone of Zeroizing-wrapped passphrase
719pub 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                // Remove expired entry (passphrase auto-zeroized on drop)
726                cache.remove(repo_path);
727            }
728        }
729    }
730    None
731}
732
733/// Clear all cached passphrases
734pub fn clear_passphrase_cache() {
735    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
736        cache.clear();
737    }
738}
739
740/// Clear cached passphrase for specific repository
741pub fn clear_cached_passphrase(repo_path: &str) {
742    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
743        cache.remove(repo_path);
744    }
745}
746
747/// Get passphrase from non-interactive sources
748///
749/// Priority: LIT_PASSPHRASE env var > LIT_PASSPHRASE_FILE env var > cache
750/// Returns None if no non-interactive source is available.
751/// SECURITY: Returns Zeroizing<String> to ensure passphrase is cleared from memory.
752fn get_passphrase_non_interactive(
753    repo_path: &str,
754    config: &EncryptionConfig,
755) -> Option<Zeroizing<String>> {
756    // 1. Check LIT_PASSPHRASE env var
757    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
758        if !pass.is_empty() {
759            return Some(Zeroizing::new(pass));
760        }
761    }
762
763    // 2. Check LIT_PASSPHRASE_FILE env var
764    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    // 3. Check cache
777    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
786/// Prompt user for passphrase securely via CLI
787///
788/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > cache > interactive prompt.
789/// In non-interactive mode (default for agents), returns error if no passphrase
790/// is available from env/file/cache.
791pub fn prompt_for_passphrase(
792    repo_path: &str,
793    config: &EncryptionConfig,
794    prompt_text: &str,
795) -> Result<Zeroizing<String>, String> {
796    // Try non-interactive sources first
797    if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
798        return Ok(pass);
799    }
800
801    // Agent safety: never block on an interactive prompt when there is no TTY
802    // (the default for agents, pipes, and CI). Fail fast with remediation.
803    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    // Fall back to interactive prompt
812    rpassword::prompt_password(prompt_text)
813        .map(Zeroizing::new)
814        .map_err(|e| format!("Failed to read passphrase: {}", e))
815}
816
817/// Minimum passphrase length (NIST SP 800-63B recommendation for high security)
818const MIN_PASSPHRASE_LENGTH: usize = 16;
819
820/// Validate passphrase strength
821///
822/// Requirements:
823/// - Minimum 16 characters (NIST SP 800-63B)
824/// - At least 3 of: uppercase, lowercase, digits, special characters
825fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
826    // SECURITY: Test bypass only available in test builds (FINDING-001)
827    #[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    // Check complexity
840    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
860/// Prompt for passphrase confirmation (for new passphrases)
861///
862/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > interactive prompt (with confirmation).
863pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
864    // Check LIT_PASSPHRASE env var
865    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    // Check LIT_PASSPHRASE_FILE env var
873    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    // Agent safety: never block on an interactive prompt when there is no TTY.
887    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    // Interactive prompt with confirmation
896    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
911/// Encryption manager for repository
912pub struct EncryptionManager {
913    config: EncryptionConfig,
914    engine: Option<EncryptionEngine>,
915    repo_path: Option<String>,
916}
917
918impl EncryptionManager {
919    /// Create new encryption manager
920    pub fn new(config: EncryptionConfig) -> Self {
921        EncryptionManager {
922            config,
923            engine: None,
924            repo_path: None,
925        }
926    }
927
928    /// Build a manager, initializing it from a non-interactive passphrase
929    /// source when encryption is enabled and one is available.
930    ///
931    /// Every command builds its object store through `ObjectStore::new`, which
932    /// returns `Self` rather than a `Result` and must not prompt — Lit is
933    /// zero-prompt by design. So the passphrase comes from `LIT_PASSPHRASE`,
934    /// `LIT_PASSPHRASE_FILE` or the cache, and nothing else.
935    ///
936    /// With encryption enabled and no source available the manager stays
937    /// uninitialized on purpose: the first encrypt or decrypt then reports
938    /// that plainly, which is a better failure than a constructor that cannot
939    /// explain itself.
940    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    /// Whether `data` carries our encryption header.
966    ///
967    /// Lets a reader tell ciphertext from content written before encryption
968    /// was switched on, so a repository part-way through migration stays
969    /// readable. Nothing we write in the clear begins with this byte: refs hold
970    /// hex or `ref: `, the index holds JSON.
971    pub fn is_encrypted_payload(data: &[u8]) -> bool {
972        data.first() == Some(&ENCRYPTION_VERSION)
973    }
974
975    /// Initialize encryption with passphrase
976    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        // A command opens several stores — the object store, the index, and the
985        // pack reader behind them — and each one lands here. Deriving the key
986        // every time means paying PBKDF2's 600,000 iterations several times
987        // over for a single `lit status`. Reuse a key already derived in this
988        // process for the same file and passphrase.
989        //
990        // The cache is memory-only and dies with the process, so it widens no
991        // window that holding the key for the length of one command already
992        // opens. Only successful derivations are stored, so a wrong passphrase
993        // still goes the long way round and still meets the rate limiter.
994        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        // Load or create encryption key
1001        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        // Create encryption engine
1013        self.engine = Some(EncryptionEngine::new(&key)?);
1014
1015        Ok(())
1016    }
1017
1018    /// Initialize encryption with passphrase caching support
1019    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        // Try to get cached passphrase first
1031        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        // Initialize encryption
1040        self.initialize(&actual_passphrase)?;
1041
1042        // Cache the passphrase if caching is enabled
1043        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    /// Encrypt data if encryption is enabled
1052    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    /// Decrypt data if encryption is enabled
1066    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                // Data written before encryption was switched on carries no
1074                // header of ours, so it fails here with a version number taken
1075                // from whatever byte happened to be first — 123 for the `{` of
1076                // the plaintext index, which explains nothing. Encryption
1077                // cannot be turned on for a repository that already has
1078                // content, and this is where a user finds that out.
1079                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    /// Check if encryption is enabled
1099    pub fn is_enabled(&self) -> bool {
1100        self.config.enabled
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    /// Serializes tests that mutate the process-global passphrase cache.
1109    ///
1110    /// Several tests call [`clear_passphrase_cache`], which wipes every entry;
1111    /// running them in parallel lets one test clear another's freshly-cached
1112    /// entry, producing spurious failures. Holding this lock makes those tests
1113    /// mutually exclusive. Poisoning is recovered from since a panic in one
1114    /// test must not cascade into the others.
1115    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    /// A key-file path belonging to a single test.
1122    ///
1123    /// Tests that exercise `EncryptionKey::save`/`load` write a real file, and
1124    /// the rate-limiter keys its failed-attempt tracker off that path. Pointing
1125    /// them at `~/.lit/encryption.key` therefore made them collide with one
1126    /// another — and, since they delete the file to start clean, destroyed the
1127    /// operator's real key on any run that included them. A per-test path in
1128    /// the temp directory isolates the file and the tracker together.
1129    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        // Same passphrase and salt should produce same key
1151        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        // Encrypt
1165        let encrypted = engine.encrypt(plaintext).unwrap();
1166
1167        // Verify encrypted data is different
1168        assert_ne!(encrypted.as_slice(), plaintext);
1169
1170        // Decrypt
1171        let decrypted = engine.decrypt(&encrypted).unwrap();
1172
1173        // Verify original data restored
1174        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        // Encrypt same data twice
1188        let encrypted1 = engine.encrypt(plaintext).unwrap();
1189        let encrypted2 = engine.encrypt(plaintext).unwrap();
1190
1191        // Should produce different ciphertexts (different nonces)
1192        assert_ne!(encrypted1, encrypted2);
1193
1194        // But both should decrypt to same plaintext
1195        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        // Tamper with ciphertext
1211        let len = encrypted.len();
1212        encrypted[len - 1] ^= 0x01;
1213
1214        // Decryption should fail due to authentication tag mismatch
1215        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        // When disabled, should return data as-is
1230        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 cache first
1241        clear_passphrase_cache();
1242
1243        // Should return None when not cached
1244        assert!(get_cached_passphrase(repo_path).is_none());
1245
1246        // Cache passphrase with 5 second timeout
1247        cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1248
1249        // Should retrieve cached passphrase
1250        assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1251
1252        // Clear specific entry
1253        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 with a short timeout, then wait well past it and assert the
1266        // entry was evicted. This test deliberately avoids asserting immediate
1267        // availability — that behavior is covered by `test_passphrase_caching`,
1268        // and a tight "available right now" check would race the timeout under
1269        // heavy parallel CPU load. Asserting only expiration is robust: more
1270        // load can only make the entry *more* expired, never less.
1271        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        // Should be expired and removed
1280        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 different passphrases for different repos
1294        cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1295        cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1296
1297        // Should retrieve correct passphrase for each repo
1298        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, // 5 minutes
1320            ..Default::default()
1321        };
1322
1323        let mut manager = EncryptionManager::new(config);
1324        let passphrase = "test-cache-manager-pass";
1325
1326        // Initialize with cache
1327        manager
1328            .initialize_with_cache(repo_str, Some(passphrase))
1329            .unwrap();
1330
1331        // Passphrase should be cached
1332        assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1333
1334        // Should be able to initialize again without providing passphrase
1335        let mut manager2 = EncryptionManager::new(manager.config.clone());
1336        manager2.initialize_with_cache(repo_str, None).unwrap();
1337
1338        // Clear cache for cleanup
1339        clear_passphrase_cache();
1340        let _ = fs::remove_file(&key_file);
1341    }
1342
1343    /// Exercises the brute-force throttle on `EncryptionKey::load`.
1344    ///
1345    /// Ignored for runtime, not correctness: each attempt that gets as far as
1346    /// verification runs PBKDF2 at 600k iterations, which costs seconds in an
1347    /// unoptimized build. Run it with `cargo test -- --ignored`.
1348    #[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        // A passphrase that does NOT start with "test-", so the test-only
1355        // bypass in `load` leaves the rate-limit check in play.
1356        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        // A wrong passphrase is rejected on its merits, and counted.
1362        assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1363
1364        // The next attempt falls inside the backoff window, so the throttle
1365        // turns it away before any verification happens. The throttle refuses
1366        // rather than sleeping, so the caller is told how long to wait instead
1367        // of having a thread parked on its behalf.
1368        // `unwrap_err` is avoided throughout: it would require `EncryptionKey`
1369        // to be `Debug`, and that type holds live key material.
1370        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        // Once the 2^1-second window passes, attempts are evaluated again — the
1385        // failure that comes back is about the passphrase, not the throttle.
1386        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        // Success clears the counter, so the next wrong attempt is judged on
1395        // its merits rather than being thrown out by the throttle.
1396        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    /// Nonces must not repeat across freshly created engines.
1409    ///
1410    /// The old construction put an engine-local counter in the top 8 bytes of
1411    /// the nonce, so every new engine — every process, every store opened —
1412    /// started again at zero and the first encryption always carried the same
1413    /// 8 leading bytes. Only 4 random bytes separated two such nonces, and a
1414    /// repeated nonce under one AES-GCM key is catastrophic. Simulate a run of
1415    /// separate processes and require the nonces to be distinct.
1416    #[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            // A fresh engine each time, as a new process would build.
1425            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        // Under the old scheme every one of these would have started 0x00 * 8.
1439        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    /// Saving over an existing key file must keep working.
1453    ///
1454    /// The key file is restricted to its owner before being renamed into
1455    /// place. On Windows that restriction is the read-only attribute, and
1456    /// `fs::rename` onto a read-only destination is exactly what `rotate-key`
1457    /// does — so if it failed, rotation would break on the second save.
1458    #[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        // The second key's salt should be what is on disk now.
1477        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    /// The restriction has to tighten a file that already exists, not only one
1488    /// this version created. Keys written before the restriction existed sit on
1489    /// disk with whatever the umask gave them, and only a call on the load path
1490    /// ever corrects them.
1491    #[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        // Whatever the platform, the owner must still be able to read it back —
1514        // a restriction that locks out the process that applied it is a bug.
1515        assert_eq!(fs::read(&path).unwrap(), b"secret");
1516    }
1517}