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