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/// Where the failed-attempt count for a key file is kept between runs.
308fn throttle_state_path(repo_path: &str) -> std::path::PathBuf {
309    std::path::PathBuf::from(format!("{}.throttle", repo_path))
310}
311
312/// The throttle state as it is written to disk. Times are Unix seconds; a
313/// `SystemTime` has no stable serialized form worth depending on here.
314#[derive(Serialize, Deserialize, Default)]
315struct PersistedThrottle {
316    count: u32,
317    last_attempt_secs: u64,
318    lockout_until_secs: Option<u64>,
319}
320
321fn to_unix(t: SystemTime) -> u64 {
322    t.duration_since(SystemTime::UNIX_EPOCH)
323        .map(|d| d.as_secs())
324        .unwrap_or(0)
325}
326
327fn from_unix(secs: u64) -> SystemTime {
328    SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
329}
330
331/// Read the stored attempt count, treating anything unreadable as a clean
332/// slate. A corrupt or missing file must not lock a legitimate user out of
333/// their own repository — the throttle exists to slow guessing, not to become
334/// a way of denying access.
335fn load_throttle(repo_path: &str) -> FailedAttemptTracker {
336    let stored: Option<PersistedThrottle> = fs::read(throttle_state_path(repo_path))
337        .ok()
338        .and_then(|raw| serde_json::from_slice(&raw).ok());
339
340    match stored {
341        Some(s) => FailedAttemptTracker {
342            count: s.count,
343            last_attempt: from_unix(s.last_attempt_secs),
344            lockout_until: s.lockout_until_secs.map(from_unix),
345        },
346        None => FailedAttemptTracker {
347            count: 0,
348            last_attempt: SystemTime::now(),
349            lockout_until: None,
350        },
351    }
352}
353
354/// Persist the attempt count so the next process sees it.
355///
356/// Best-effort: a repository on read-only media should still be usable, and
357/// failing the operation because the throttle could not be written would turn
358/// a hardening measure into an outage.
359fn store_throttle(repo_path: &str, tracker: &FailedAttemptTracker) {
360    let state = PersistedThrottle {
361        count: tracker.count,
362        last_attempt_secs: to_unix(tracker.last_attempt),
363        lockout_until_secs: tracker.lockout_until.map(to_unix),
364    };
365
366    let path = throttle_state_path(repo_path);
367    if let Ok(raw) = serde_json::to_vec(&state) {
368        if fs::write(&path, raw).is_ok() {
369            // The file says how many times someone has recently failed to open
370            // this key, which is worth no more exposure than the key itself.
371            let _ = restrict_to_owner(&path);
372        }
373    }
374}
375
376/// Check rate limit for passphrase attempts
377/// Returns Ok(()) if attempt is allowed, Err with message if rate limited
378///
379/// The count lives on disk rather than in this process. Every `lit` command is
380/// a new process, so an in-memory counter starts at zero for each one: a script
381/// that reruns the binary was never slowed by the backoff and never reached the
382/// five-attempt lockout at all, which is the case the throttle exists for.
383///
384/// This raises the cost of guessing; it does not stop an attacker who can
385/// delete the state file. That is the same directory as the key file, so such
386/// an attacker is already inside the boundary the throttle assumes — PBKDF2 at
387/// 600k iterations remains the defence that does not depend on that assumption.
388fn check_rate_limit(repo_path: &str) -> Result<(), String> {
389    let _serialize = FAILED_ATTEMPTS
390        .lock()
391        .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
392    let mut tracker = load_throttle(repo_path);
393
394    // Check if currently locked out
395    if let Some(lockout) = tracker.lockout_until {
396        if SystemTime::now() < lockout {
397            let remaining = lockout
398                .duration_since(SystemTime::now())
399                .unwrap_or(Duration::from_secs(0));
400            return Err(format!(
401                "Too many failed attempts. Please wait {} seconds before trying again.",
402                remaining.as_secs()
403            ));
404        }
405        // Lockout expired, reset counter
406        tracker.lockout_until = None;
407        tracker.count = 0;
408        store_throttle(repo_path, &tracker);
409    }
410
411    // Apply exponential backoff: 2^n seconds (max 32 seconds for n=5)
412    if tracker.count > 0 {
413        let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
414        if let Ok(elapsed) = tracker.last_attempt.elapsed() {
415            if elapsed < delay {
416                let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
417                return Err(format!(
418                    "Please wait {} seconds between passphrase attempts.",
419                    remaining
420                ));
421            }
422        }
423    }
424
425    Ok(())
426}
427
428/// Record a failed passphrase attempt
429fn record_failed_attempt(repo_path: &str) {
430    let Ok(_serialize) = FAILED_ATTEMPTS.lock() else {
431        return;
432    };
433    let mut tracker = load_throttle(repo_path);
434
435    tracker.count += 1;
436    tracker.last_attempt = SystemTime::now();
437
438    // Lock out for 5 minutes after 5 failed attempts
439    if tracker.count >= 5 {
440        tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
441        eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
442    }
443
444    store_throttle(repo_path, &tracker);
445}
446
447/// Clear failed attempt counter (called on successful authentication)
448fn clear_failed_attempts(repo_path: &str) {
449    if let Ok(_serialize) = FAILED_ATTEMPTS.lock() {
450        // A correct passphrase clears the record, so an ordinary typo costs a
451        // few seconds and nothing more once the user gets it right.
452        let _ = fs::remove_file(throttle_state_path(repo_path));
453    }
454}
455
456/// Secure encryption key with automatic zeroization
457#[derive(ZeroizeOnDrop)]
458#[allow(unused_assignments)]
459pub struct EncryptionKey {
460    key_bytes: [u8; KEY_SIZE],
461    /// Salt used to derive this key (needed for saving)
462    #[zeroize(skip)]
463    salt: [u8; SALT_SIZE],
464}
465
466impl EncryptionKey {
467    /// Derive key from passphrase using PBKDF2-HMAC-SHA512
468    pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
469        // SECURITY: Test bypass only available in test builds (FINDING-001)
470        #[cfg(not(test))]
471        validate_passphrase_strength(passphrase)?;
472        #[cfg(test)]
473        if !passphrase.starts_with("test-") {
474            validate_passphrase_strength(passphrase)?;
475        }
476
477        if salt.len() != SALT_SIZE {
478            return Err(format!(
479                "Invalid salt size: expected {}, got {}",
480                SALT_SIZE,
481                salt.len()
482            ));
483        }
484
485        let mut key_bytes = [0u8; KEY_SIZE];
486        pbkdf2_hmac::<Sha512>(
487            passphrase.as_bytes(),
488            salt,
489            PBKDF2_ITERATIONS,
490            &mut key_bytes,
491        );
492
493        let mut salt_array = [0u8; SALT_SIZE];
494        salt_array.copy_from_slice(salt);
495
496        Ok(EncryptionKey {
497            key_bytes,
498            salt: salt_array,
499        })
500    }
501
502    /// Generate a random salt for key derivation
503    pub fn generate_salt() -> [u8; SALT_SIZE] {
504        use aes_gcm::aead::rand_core::RngCore;
505        let mut salt = [0u8; SALT_SIZE];
506        OsRng.fill_bytes(&mut salt);
507        salt
508    }
509
510    /// Load key from encrypted key file
511    /// SECURITY: Verifies passphrase using stored hash (constant-time comparison)
512    /// SECURITY: Rate limiting prevents brute force attacks
513    pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
514        // SECURITY: Rate limit check — test bypass only in test builds (FINDING-001)
515        let key_file_str = key_file.to_string_lossy().to_string();
516        #[cfg(not(test))]
517        check_rate_limit(&key_file_str)?;
518        #[cfg(test)]
519        if !passphrase.starts_with("test-") {
520            check_rate_limit(&key_file_str)?;
521        }
522
523        if !key_file.exists() {
524            return Err(
525                "Encryption key file not found. Initialize repository with encryption first."
526                    .to_string(),
527            );
528        }
529
530        // Re-apply on load, not only at creation. The salt in this file is what
531        // an offline brute force of the passphrase needs, and a key file written
532        // before the restriction existed keeps its inherited ACL for the life of
533        // the installation otherwise — the one on this machine dates to March.
534        restrict_to_owner(key_file)?;
535
536        let encrypted_data =
537            fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
538
539        if encrypted_data.len() < SALT_SIZE + 1 {
540            return Err("Invalid key file format (too short)".to_string());
541        }
542
543        // Extract components
544        let salt = &encrypted_data[0..SALT_SIZE];
545        let version = encrypted_data[SALT_SIZE];
546
547        if version != ENCRYPTION_VERSION {
548            return Err(format!("Unsupported key file version: {}", version));
549        }
550
551        // Check if old format (no verification hash) or new format
552        if encrypted_data.len() == SALT_SIZE + 1 {
553            // Old format - just derive key (backward compatibility)
554            let key = Self::from_passphrase(passphrase, salt)?;
555            // Clear failed attempts on successful load
556            clear_failed_attempts(&key_file_str);
557            return Ok(key);
558        }
559
560        if encrypted_data.len() < SALT_SIZE + 1 + 32 {
561            return Err("Invalid key file format (unexpected size)".to_string());
562        }
563
564        let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
565
566        // Derive key from passphrase
567        let key = Self::from_passphrase(passphrase, salt)?;
568
569        // Verify passphrase using constant-time comparison
570        use sha2::{Digest, Sha256};
571        let mut hasher = Sha256::new();
572        hasher.update(b"lit-passphrase-verification-v1");
573        hasher.update(&key.key_bytes);
574        let verification_hash = hasher.finalize();
575
576        // Constant-time comparison to prevent timing attacks
577        use subtle::ConstantTimeEq;
578        if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
579            // SECURITY: Record failed attempt — test bypass only in test builds (FINDING-001)
580            #[cfg(not(test))]
581            record_failed_attempt(&key_file_str);
582            #[cfg(test)]
583            if !passphrase.starts_with("test-") {
584                record_failed_attempt(&key_file_str);
585            }
586            // Add delay to prevent timing-based passphrase enumeration
587            std::thread::sleep(std::time::Duration::from_millis(100));
588            return Err("Invalid passphrase".to_string());
589        }
590
591        // Clear failed attempts on successful authentication
592        clear_failed_attempts(&key_file_str);
593        Ok(key)
594    }
595
596    /// Save key to encrypted key file
597    /// SECURITY: Uses atomic write (temp file + rename) to prevent corruption
598    /// on crash or power loss. Includes verification hash for passphrase validation.
599    pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
600        let expanded = shellexpand::tilde(key_file_str);
601        let key_file = Path::new(expanded.as_ref());
602
603        // Generate verification hash using current key
604        use sha2::{Digest, Sha256};
605        let mut hasher = Sha256::new();
606        hasher.update(b"lit-passphrase-verification-v1");
607        hasher.update(self.key_bytes);
608        let verification_hash = hasher.finalize();
609
610        // Create key file directory if needed
611        if let Some(parent) = key_file.parent() {
612            fs::create_dir_all(parent)
613                .map_err(|e| format!("Failed to create key directory: {}", e))?;
614        }
615
616        // Store: salt + version + verification_hash
617        let mut data = Vec::new();
618        data.extend_from_slice(&self.salt);
619        data.push(ENCRYPTION_VERSION);
620        data.extend_from_slice(&verification_hash);
621
622        // Atomic write: write to temp file then rename to prevent corruption
623        let temp_file = key_file.with_extension("tmp");
624        fs::write(&temp_file, &data)
625            .map_err(|e| format!("Failed to write temp key file: {}", e))?;
626
627        // Restrict before the file takes its real name, so it is never briefly
628        // readable under the path an attacker would watch.
629        //
630        // No key material is stored here — the key is derived from the
631        // passphrase and this salt — but the verification hash lets anyone
632        // holding the file test passphrase guesses offline, without needing the
633        // repository at all. That is worth keeping to the owner.
634        restrict_to_owner(&temp_file)?;
635
636        // Windows refuses to rename onto a read-only file, and the file being
637        // replaced is one this function marked read-only last time. Clearing
638        // the attribute first is what lets `rotate-key` save a second time; a
639        // test covers it, because the failure only appears on the second save.
640        allow_replacement(key_file)?;
641
642        fs::rename(&temp_file, key_file)
643            .map_err(|e| format!("Failed to rename key file: {}", e))?;
644
645        Ok(())
646    }
647
648    /// Get raw key bytes (used internally)
649    fn as_bytes(&self) -> &[u8; KEY_SIZE] {
650        &self.key_bytes
651    }
652}
653
654/// Maximum encryptions per key (NIST SP 800-38D recommendation)
655/// Never exceed 2^32 encryptions with same key to prevent nonce reuse
656const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
657
658/// Encryption engine using AES-256-GCM
659/// SECURITY: Uses atomic counter to guarantee nonce uniqueness
660pub struct EncryptionEngine {
661    cipher: Aes256Gcm,
662    /// Atomic counter for nonce generation (ensures uniqueness)
663    nonce_counter: AtomicU64,
664}
665
666impl EncryptionEngine {
667    /// Create new encryption engine with key
668    pub fn new(key: &EncryptionKey) -> Result<Self, String> {
669        // Every AES-GCM operation in the crate goes through an engine, so this
670        // is the one place that can promise the self-tests ran first no matter
671        // which binary is driving. Runs once per process.
672        crate::crypto::fips::ensure_self_tests()?;
673
674        let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
675            .map_err(|e| format!("Failed to create cipher: {}", e))?;
676
677        Ok(EncryptionEngine {
678            cipher,
679            nonce_counter: AtomicU64::new(0),
680        })
681    }
682
683    /// Encrypt data with authenticated encryption (AES-256-GCM)
684    ///
685    /// Format: [version: 1 byte][nonce: 12 bytes][ciphertext + auth tag]
686    /// SECURITY: Uses counter-based nonce to guarantee uniqueness
687    #[allow(deprecated)]
688    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
689        // Invocation limit for a random nonce (NIST SP 800-38D §8.3).
690        //
691        // The counter is per engine, so this bounds one process rather than the
692        // lifetime of the key; a durable count would need state that survives
693        // the command. It is a backstop, not the guarantee — `rotate-key`
694        // remains the real control.
695        let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
696        if count >= MAX_ENCRYPTIONS_PER_KEY {
697            return Err(format!(
698                "Encryption limit exceeded ({} operations). Key rotation required for security.",
699                MAX_ENCRYPTIONS_PER_KEY
700            ));
701        }
702
703        // Nonce: 96 random bits, the RBG-based construction of NIST SP 800-38D
704        // §8.2.2, which is why the invocation limit above is 2^32.
705        //
706        // This was previously a counter in the top 8 bytes with 4 random bytes
707        // after it, described as guaranteeing uniqueness. It did not: the
708        // counter lives in the engine and restarts at zero for every engine —
709        // every process, and every store or index opened within one — so the
710        // first encryption after each start always reused counter 0 and only
711        // those 4 random bytes stood between two nonces. Colliding 32 bits is
712        // a birthday problem over roughly 65,000 encryptions, and a repeated
713        // nonce under one AES-GCM key does not merely leak the XOR of the two
714        // plaintexts, it exposes the GHASH key and with it forgery.
715        //
716        // 96 random bits put the same collision out of reach, and the nonce is
717        // stored alongside the ciphertext, so data written under the old scheme
718        // still decrypts.
719        use aes_gcm::aead::rand_core::RngCore;
720        let mut nonce_bytes = [0u8; NONCE_SIZE];
721        OsRng.fill_bytes(&mut nonce_bytes);
722        let nonce = Nonce::from_slice(&nonce_bytes);
723
724        // Encrypt with authenticated encryption
725        let ciphertext = self
726            .cipher
727            .encrypt(nonce, plaintext)
728            .map_err(|e| format!("Encryption failed: {}", e))?;
729
730        // Build output: version + nonce + ciphertext
731        let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
732        output.push(ENCRYPTION_VERSION);
733        output.extend_from_slice(&nonce_bytes);
734        output.extend_from_slice(&ciphertext);
735
736        Ok(output)
737    }
738
739    /// Decrypt data with authentication verification
740    #[allow(deprecated)]
741    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
742        if encrypted.len() < 1 + NONCE_SIZE {
743            return Err("Invalid encrypted data: too short".to_string());
744        }
745
746        // Extract version
747        let version = encrypted[0];
748        if version != ENCRYPTION_VERSION {
749            return Err(format!("Unsupported encryption version: {}", version));
750        }
751
752        // Extract nonce
753        let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
754        let nonce = Nonce::from_slice(nonce_bytes);
755
756        // Extract ciphertext
757        let ciphertext = &encrypted[1 + NONCE_SIZE..];
758
759        // Decrypt and verify authentication tag
760        let plaintext = self
761            .cipher
762            .decrypt(nonce, ciphertext)
763            .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
764
765        Ok(plaintext)
766    }
767}
768
769/// Passphrase cache operations
770impl CachedPassphrase {
771    /// Check if cached passphrase is still valid
772    fn is_valid(&self) -> bool {
773        SystemTime::now() < self.expires_at
774    }
775}
776
777/// Store passphrase in cache with timeout
778/// SECURITY: Passphrase stored in Zeroizing wrapper for automatic memory clearing
779pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
780    let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
781    let expires_at = SystemTime::now() + timeout;
782
783    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
784        cache.insert(
785            repo_path.to_string(),
786            CachedPassphrase {
787                passphrase: Zeroizing::new(passphrase),
788                expires_at,
789            },
790        );
791    }
792}
793
794/// Retrieve cached passphrase if valid
795/// SECURITY: Returns clone of Zeroizing-wrapped passphrase
796pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
797    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
798        if let Some(entry) = cache.get(repo_path) {
799            if entry.is_valid() {
800                return Some(entry.passphrase.clone());
801            } else {
802                // Remove expired entry (passphrase auto-zeroized on drop)
803                cache.remove(repo_path);
804            }
805        }
806    }
807    None
808}
809
810/// Clear all cached passphrases
811pub fn clear_passphrase_cache() {
812    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
813        cache.clear();
814    }
815}
816
817/// Clear cached passphrase for specific repository
818pub fn clear_cached_passphrase(repo_path: &str) {
819    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
820        cache.remove(repo_path);
821    }
822}
823
824/// Get passphrase from non-interactive sources
825///
826/// Priority: LIT_PASSPHRASE env var > LIT_PASSPHRASE_FILE env var > cache
827/// Returns None if no non-interactive source is available.
828/// SECURITY: Returns Zeroizing<String> to ensure passphrase is cleared from memory.
829fn get_passphrase_non_interactive(
830    repo_path: &str,
831    config: &EncryptionConfig,
832) -> Option<Zeroizing<String>> {
833    // 1. Check LIT_PASSPHRASE env var
834    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
835        if !pass.is_empty() {
836            return Some(Zeroizing::new(pass));
837        }
838    }
839
840    // 2. Check LIT_PASSPHRASE_FILE env var
841    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
842        if let Ok(pass) = std::fs::read_to_string(&path) {
843            let pass = pass
844                .trim_end_matches('\n')
845                .trim_end_matches('\r')
846                .to_string();
847            if !pass.is_empty() {
848                return Some(Zeroizing::new(pass));
849            }
850        }
851    }
852
853    // 3. Check cache
854    if config.cache_timeout_secs > 0 {
855        if let Some(cached) = get_cached_passphrase(repo_path) {
856            return Some(cached);
857        }
858    }
859
860    // 4. Ask the agent, if one is running. This is the only source that spans
861    //    commands — the cache above cannot, since each command is a new
862    //    process. Deliberately last: an explicitly supplied passphrase should
863    //    win over a stored one, so that overriding it does not require stopping
864    //    the agent first.
865    if let Some(from_agent) = crate::crypto::agent::get(repo_path) {
866        return Some(from_agent);
867    }
868
869    None
870}
871
872/// Prompt user for passphrase securely via CLI
873///
874/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > cache > interactive prompt.
875/// In non-interactive mode (default for agents), returns error if no passphrase
876/// is available from env/file/cache.
877pub fn prompt_for_passphrase(
878    repo_path: &str,
879    config: &EncryptionConfig,
880    prompt_text: &str,
881) -> Result<Zeroizing<String>, String> {
882    // Try non-interactive sources first
883    if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
884        return Ok(pass);
885    }
886
887    // Agent safety: never block on an interactive prompt when there is no TTY
888    // (the default for agents, pipes, and CI). Fail fast with remediation.
889    if !std::io::stdin().is_terminal() {
890        return Err(
891            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
892             LIT_PASSPHRASE_FILE"
893                .to_string(),
894        );
895    }
896
897    // Fall back to interactive prompt
898    rpassword::prompt_password(prompt_text)
899        .map(Zeroizing::new)
900        .map_err(|e| format!("Failed to read passphrase: {}", e))
901}
902
903/// Minimum passphrase length (NIST SP 800-63B recommendation for high security)
904const MIN_PASSPHRASE_LENGTH: usize = 16;
905
906/// Validate passphrase strength
907///
908/// Requirements:
909/// - Minimum 16 characters (NIST SP 800-63B)
910/// - At least 3 of: uppercase, lowercase, digits, special characters
911fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
912    // SECURITY: Test bypass only available in test builds (FINDING-001)
913    #[cfg(test)]
914    if passphrase.starts_with("test-") {
915        return Ok(());
916    }
917
918    if passphrase.len() < MIN_PASSPHRASE_LENGTH {
919        return Err(format!(
920            "Passphrase must be at least {} characters (recommended: 20+)",
921            MIN_PASSPHRASE_LENGTH
922        ));
923    }
924
925    // Check complexity
926    let has_upper = passphrase.chars().any(|c| c.is_uppercase());
927    let has_lower = passphrase.chars().any(|c| c.is_lowercase());
928    let has_digit = passphrase.chars().any(|c| c.is_numeric());
929    let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
930
931    let complexity_count = [has_upper, has_lower, has_digit, has_special]
932        .iter()
933        .filter(|&&x| x)
934        .count();
935
936    if complexity_count < 3 {
937        return Err(
938            "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
939                .to_string(),
940        );
941    }
942
943    Ok(())
944}
945
946/// Prompt for passphrase confirmation (for new passphrases)
947///
948/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > interactive prompt (with confirmation).
949pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
950    // Check LIT_PASSPHRASE env var
951    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
952        if !pass.is_empty() {
953            validate_passphrase_strength(&pass)?;
954            return Ok(Zeroizing::new(pass));
955        }
956    }
957
958    // Check LIT_PASSPHRASE_FILE env var
959    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
960        if let Ok(pass) = std::fs::read_to_string(&path) {
961            let pass = pass
962                .trim_end_matches('\n')
963                .trim_end_matches('\r')
964                .to_string();
965            if !pass.is_empty() {
966                validate_passphrase_strength(&pass)?;
967                return Ok(Zeroizing::new(pass));
968            }
969        }
970    }
971
972    // Agent safety: never block on an interactive prompt when there is no TTY.
973    if !std::io::stdin().is_terminal() {
974        return Err(
975            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
976             LIT_PASSPHRASE_FILE"
977                .to_string(),
978        );
979    }
980
981    // Interactive prompt with confirmation
982    let pass1 = rpassword::prompt_password(prompt_text)
983        .map_err(|e| format!("Failed to read passphrase: {}", e))?;
984
985    let pass2 = rpassword::prompt_password("Confirm passphrase: ")
986        .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
987
988    if pass1 != pass2 {
989        return Err("Passphrases do not match".to_string());
990    }
991
992    validate_passphrase_strength(&pass1)?;
993
994    Ok(Zeroizing::new(pass1))
995}
996
997/// Encryption manager for repository
998pub struct EncryptionManager {
999    config: EncryptionConfig,
1000    engine: Option<EncryptionEngine>,
1001    repo_path: Option<String>,
1002}
1003
1004impl EncryptionManager {
1005    /// Create new encryption manager
1006    pub fn new(config: EncryptionConfig) -> Self {
1007        EncryptionManager {
1008            config,
1009            engine: None,
1010            repo_path: None,
1011        }
1012    }
1013
1014    /// Build a manager, initializing it from a non-interactive passphrase
1015    /// source when encryption is enabled and one is available.
1016    ///
1017    /// Every command builds its object store through `ObjectStore::new`, which
1018    /// returns `Self` rather than a `Result` and must not prompt — Lit is
1019    /// zero-prompt by design. So the passphrase comes from `LIT_PASSPHRASE`,
1020    /// `LIT_PASSPHRASE_FILE` or the cache, and nothing else.
1021    ///
1022    /// With encryption enabled and no source available the manager stays
1023    /// uninitialized on purpose: the first encrypt or decrypt then reports
1024    /// that plainly, which is a better failure than a constructor that cannot
1025    /// explain itself.
1026    pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
1027        let mut manager = EncryptionManager::new(config);
1028        if !manager.config.enabled {
1029            return manager;
1030        }
1031
1032        let repo = repo_path.to_string_lossy().to_string();
1033        let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
1034            return manager;
1035        };
1036
1037        manager.repo_path = Some(repo.clone());
1038        if let Err(e) = manager.initialize(&passphrase) {
1039            eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
1040            return manager;
1041        }
1042
1043        if manager.config.cache_timeout_secs > 0 {
1044            let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
1045            cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
1046        }
1047
1048        manager
1049    }
1050
1051    /// Whether `data` carries our encryption header.
1052    ///
1053    /// Lets a reader tell ciphertext from content written before encryption
1054    /// was switched on, so a repository part-way through migration stays
1055    /// readable. Nothing we write in the clear begins with this byte: refs hold
1056    /// hex or `ref: `, the index holds JSON.
1057    pub fn is_encrypted_payload(data: &[u8]) -> bool {
1058        data.first() == Some(&ENCRYPTION_VERSION)
1059    }
1060
1061    /// Initialize encryption with passphrase
1062    pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
1063        if !self.config.enabled {
1064            return Ok(());
1065        }
1066
1067        let expanded = shellexpand::tilde(&self.config.key_file);
1068        let key_file = Path::new(expanded.as_ref());
1069
1070        // A command opens several stores — the object store, the index, and the
1071        // pack reader behind them — and each one lands here. Deriving the key
1072        // every time means paying PBKDF2's 600,000 iterations several times
1073        // over for a single `lit status`. Reuse a key already derived in this
1074        // process for the same file and passphrase.
1075        //
1076        // The cache is memory-only and dies with the process, so it widens no
1077        // window that holding the key for the length of one command already
1078        // opens. Only successful derivations are stored, so a wrong passphrase
1079        // still goes the long way round and still meets the rate limiter.
1080        let cache_id = derived_key_id(expanded.as_ref(), passphrase);
1081        if let Some(key) = cached_derived_key(&cache_id) {
1082            self.engine = Some(EncryptionEngine::new(&key)?);
1083            return Ok(());
1084        }
1085
1086        // Load or create encryption key
1087        let key = if key_file.exists() {
1088            EncryptionKey::load(key_file, passphrase)?
1089        } else {
1090            let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1091            key.save(&self.config.key_file, passphrase)?;
1092            key
1093        };
1094
1095        let key = std::sync::Arc::new(key);
1096        remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1097
1098        // Create encryption engine
1099        self.engine = Some(EncryptionEngine::new(&key)?);
1100
1101        Ok(())
1102    }
1103
1104    /// Initialize encryption with passphrase caching support
1105    pub fn initialize_with_cache(
1106        &mut self,
1107        repo_path: &str,
1108        passphrase: Option<&str>,
1109    ) -> Result<(), String> {
1110        if !self.config.enabled {
1111            return Ok(());
1112        }
1113
1114        self.repo_path = Some(repo_path.to_string());
1115
1116        // Try to get cached passphrase first
1117        let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1118            Zeroizing::new(pass.to_string())
1119        } else if let Some(cached) = get_cached_passphrase(repo_path) {
1120            cached
1121        } else {
1122            return Err("No passphrase provided and no valid cached passphrase found".to_string());
1123        };
1124
1125        // Initialize encryption
1126        self.initialize(&actual_passphrase)?;
1127
1128        // Cache the passphrase if caching is enabled
1129        if self.config.cache_timeout_secs > 0 {
1130            let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1131            cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1132        }
1133
1134        Ok(())
1135    }
1136
1137    /// Encrypt data if encryption is enabled
1138    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1139        if !self.config.enabled {
1140            return Ok(plaintext.to_vec());
1141        }
1142
1143        match &self.engine {
1144            Some(engine) => engine.encrypt(plaintext),
1145            None => Err(
1146                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1147            ),
1148        }
1149    }
1150
1151    /// Decrypt data if encryption is enabled
1152    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1153        if !self.config.enabled {
1154            return Ok(encrypted.to_vec());
1155        }
1156
1157        match &self.engine {
1158            Some(engine) => {
1159                // Data written before encryption was switched on carries no
1160                // header of ours, so it fails here with a version number taken
1161                // from whatever byte happened to be first — 123 for the `{` of
1162                // the plaintext index, which explains nothing. Encryption
1163                // cannot be turned on for a repository that already has
1164                // content, and this is where a user finds that out.
1165                if encrypted
1166                    .first()
1167                    .is_some_and(|version| *version != ENCRYPTION_VERSION)
1168                {
1169                    return Err(
1170                        "This data has no Lit encryption header. Encryption cannot be \
1171                         enabled for a repository that already contains unencrypted \
1172                         commits — start a new encrypted repository and import into it."
1173                            .to_string(),
1174                    );
1175                }
1176                engine.decrypt(encrypted)
1177            }
1178            None => Err(
1179                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1180            ),
1181        }
1182    }
1183
1184    /// Check if encryption is enabled
1185    pub fn is_enabled(&self) -> bool {
1186        self.config.enabled
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193
1194    /// Serializes tests that mutate the process-global passphrase cache.
1195    ///
1196    /// Several tests call [`clear_passphrase_cache`], which wipes every entry;
1197    /// running them in parallel lets one test clear another's freshly-cached
1198    /// entry, producing spurious failures. Holding this lock makes those tests
1199    /// mutually exclusive. Poisoning is recovered from since a panic in one
1200    /// test must not cascade into the others.
1201    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1202
1203    fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1204        CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1205    }
1206
1207    /// A key-file path belonging to a single test.
1208    ///
1209    /// Tests that exercise `EncryptionKey::save`/`load` write a real file, and
1210    /// the rate-limiter keys its failed-attempt tracker off that path. Pointing
1211    /// them at `~/.lit/encryption.key` therefore made them collide with one
1212    /// another — and, since they delete the file to start clean, destroyed the
1213    /// operator's real key on any run that included them. A per-test path in
1214    /// the temp directory isolates the file and the tracker together.
1215    fn test_key_path(label: &str) -> std::path::PathBuf {
1216        static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1217        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1218        let path = std::env::temp_dir().join(format!(
1219            "lit_enc_test_{}_{}_{}.key",
1220            std::process::id(),
1221            label,
1222            n
1223        ));
1224        let _ = fs::remove_file(&path);
1225        path
1226    }
1227
1228    #[test]
1229    fn test_key_derivation() {
1230        let passphrase = "test-passphrase-12345";
1231        let salt = EncryptionKey::generate_salt();
1232
1233        let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1234        let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1235
1236        // Same passphrase and salt should produce same key
1237        assert_eq!(key1.as_bytes(), key2.as_bytes());
1238    }
1239
1240    #[test]
1241    fn test_encryption_decryption() {
1242        let passphrase = "test-secure-passphrase";
1243        let salt = EncryptionKey::generate_salt();
1244        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1245
1246        let engine = EncryptionEngine::new(&key).unwrap();
1247
1248        let plaintext = b"Hello, this is secret data!";
1249
1250        // Encrypt
1251        let encrypted = engine.encrypt(plaintext).unwrap();
1252
1253        // Verify encrypted data is different
1254        assert_ne!(encrypted.as_slice(), plaintext);
1255
1256        // Decrypt
1257        let decrypted = engine.decrypt(&encrypted).unwrap();
1258
1259        // Verify original data restored
1260        assert_eq!(decrypted.as_slice(), plaintext);
1261    }
1262
1263    #[test]
1264    fn test_encryption_nonce_randomness() {
1265        let passphrase = "test-passphrase";
1266        let salt = EncryptionKey::generate_salt();
1267        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1268
1269        let engine = EncryptionEngine::new(&key).unwrap();
1270
1271        let plaintext = b"Same data";
1272
1273        // Encrypt same data twice
1274        let encrypted1 = engine.encrypt(plaintext).unwrap();
1275        let encrypted2 = engine.encrypt(plaintext).unwrap();
1276
1277        // Should produce different ciphertexts (different nonces)
1278        assert_ne!(encrypted1, encrypted2);
1279
1280        // But both should decrypt to same plaintext
1281        assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1282        assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1283    }
1284
1285    #[test]
1286    fn test_tampering_detection() {
1287        let passphrase = "test-passphrase";
1288        let salt = EncryptionKey::generate_salt();
1289        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1290
1291        let engine = EncryptionEngine::new(&key).unwrap();
1292
1293        let plaintext = b"Secret data";
1294        let mut encrypted = engine.encrypt(plaintext).unwrap();
1295
1296        // Tamper with ciphertext
1297        let len = encrypted.len();
1298        encrypted[len - 1] ^= 0x01;
1299
1300        // Decryption should fail due to authentication tag mismatch
1301        assert!(engine.decrypt(&encrypted).is_err());
1302    }
1303
1304    #[test]
1305    fn test_encryption_manager_disabled() {
1306        let config = EncryptionConfig {
1307            enabled: false,
1308            ..Default::default()
1309        };
1310
1311        let manager = EncryptionManager::new(config);
1312
1313        let data = b"Some data";
1314
1315        // When disabled, should return data as-is
1316        assert_eq!(manager.encrypt(data).unwrap(), data);
1317        assert_eq!(manager.decrypt(data).unwrap(), data);
1318    }
1319
1320    #[test]
1321    fn test_passphrase_caching() {
1322        let _guard = cache_test_guard();
1323        let repo_path = "/tmp/test-repo";
1324        let passphrase = "cache-test-passphrase".to_string();
1325
1326        // Clear cache first
1327        clear_passphrase_cache();
1328
1329        // Should return None when not cached
1330        assert!(get_cached_passphrase(repo_path).is_none());
1331
1332        // Cache passphrase with 5 second timeout
1333        cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1334
1335        // Should retrieve cached passphrase
1336        assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1337
1338        // Clear specific entry
1339        clear_cached_passphrase(repo_path);
1340        assert!(get_cached_passphrase(repo_path).is_none());
1341    }
1342
1343    #[test]
1344    fn test_passphrase_cache_expiration() {
1345        let _guard = cache_test_guard();
1346        let repo_path = "/tmp/test-repo-expire";
1347        let passphrase = "expire-test".to_string();
1348
1349        clear_passphrase_cache();
1350
1351        // Cache with a short timeout, then wait well past it and assert the
1352        // entry was evicted. This test deliberately avoids asserting immediate
1353        // availability — that behavior is covered by `test_passphrase_caching`,
1354        // and a tight "available right now" check would race the timeout under
1355        // heavy parallel CPU load. Asserting only expiration is robust: more
1356        // load can only make the entry *more* expired, never less.
1357        cache_passphrase(
1358            repo_path,
1359            passphrase.clone(),
1360            Some(Duration::from_millis(200)),
1361        );
1362
1363        std::thread::sleep(Duration::from_millis(600));
1364
1365        // Should be expired and removed
1366        assert!(get_cached_passphrase(repo_path).is_none());
1367    }
1368
1369    #[test]
1370    fn test_passphrase_cache_multiple_repos() {
1371        let _guard = cache_test_guard();
1372        let repo1 = "/tmp/multi-cache-repo1";
1373        let repo2 = "/tmp/multi-cache-repo2";
1374        let pass1 = "password1".to_string();
1375        let pass2 = "password2".to_string();
1376
1377        clear_passphrase_cache();
1378
1379        // Cache different passphrases for different repos
1380        cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1381        cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1382
1383        // Should retrieve correct passphrase for each repo
1384        assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1385        assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1386    }
1387
1388    #[test]
1389    fn test_encryption_manager_with_cache() {
1390        use std::env;
1391
1392        let _guard = cache_test_guard();
1393
1394        let key_file = test_key_path("manager_cache");
1395
1396        let temp_dir = env::temp_dir();
1397        let repo_path = temp_dir.join("test-cache-manager");
1398        let repo_str = repo_path.to_str().unwrap();
1399
1400        clear_passphrase_cache();
1401
1402        let config = EncryptionConfig {
1403            enabled: true,
1404            key_file: key_file.to_string_lossy().into_owned(),
1405            cache_timeout_secs: 300, // 5 minutes
1406            ..Default::default()
1407        };
1408
1409        let mut manager = EncryptionManager::new(config);
1410        let passphrase = "test-cache-manager-pass";
1411
1412        // Initialize with cache
1413        manager
1414            .initialize_with_cache(repo_str, Some(passphrase))
1415            .unwrap();
1416
1417        // Passphrase should be cached
1418        assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1419
1420        // Should be able to initialize again without providing passphrase
1421        let mut manager2 = EncryptionManager::new(manager.config.clone());
1422        manager2.initialize_with_cache(repo_str, None).unwrap();
1423
1424        // Clear cache for cleanup
1425        clear_passphrase_cache();
1426        let _ = fs::remove_file(&key_file);
1427    }
1428
1429    /// The throttle has to outlive the process that recorded the attempts.
1430    ///
1431    /// Every `lit` command is a new process. While the counter lived in a
1432    /// `static`, each one started at zero: the exponential backoff never grew
1433    /// past its first step and the five-attempt lockout could not be reached at
1434    /// all by a script that reran the binary, which is the case it exists for.
1435    /// Reading the state back from disk is what a second process does, so that
1436    /// is what this asserts.
1437    #[test]
1438    fn test_throttle_state_outlives_the_process() {
1439        let dir = tempfile::tempdir().unwrap();
1440        let key_path = dir.path().join("outlives.key");
1441        let key = key_path.to_string_lossy().into_owned();
1442
1443        for _ in 0..5 {
1444            record_failed_attempt(&key);
1445        }
1446
1447        // What a freshly started process would see.
1448        let seen = load_throttle(&key);
1449        assert_eq!(seen.count, 5, "the count should have survived on disk");
1450        assert!(
1451            seen.lockout_until.is_some(),
1452            "five failures should have produced a lockout a new process can see"
1453        );
1454
1455        // And it should actually refuse, rather than merely recording a number.
1456        assert!(
1457            check_rate_limit(&key).is_err(),
1458            "a locked-out key should be refused"
1459        );
1460
1461        // A correct passphrase clears it, so an ordinary typo is not sticky.
1462        clear_failed_attempts(&key);
1463        assert_eq!(load_throttle(&key).count, 0);
1464        assert!(check_rate_limit(&key).is_ok());
1465    }
1466
1467    /// Corrupt or unreadable state must not lock the owner out of their own
1468    /// repository — the throttle slows guessing, it is not an access control.
1469    #[test]
1470    fn test_unreadable_throttle_state_is_treated_as_a_clean_slate() {
1471        let dir = tempfile::tempdir().unwrap();
1472        let key_path = dir.path().join("corrupt.key");
1473        let key = key_path.to_string_lossy().into_owned();
1474
1475        fs::write(throttle_state_path(&key), b"this is not json").unwrap();
1476
1477        assert_eq!(load_throttle(&key).count, 0);
1478        assert!(check_rate_limit(&key).is_ok());
1479    }
1480
1481    /// Exercises the brute-force throttle on `EncryptionKey::load`.
1482    ///
1483    /// Ignored for runtime, not correctness: each attempt that gets as far as
1484    /// verification runs PBKDF2 at 600k iterations, which costs seconds in an
1485    /// unoptimized build. Run it with `cargo test -- --ignored`.
1486    #[test]
1487    #[ignore]
1488    fn test_rate_limiting() {
1489        let key_file = test_key_path("rate_limiting");
1490        let key_file_str = key_file.to_string_lossy().into_owned();
1491
1492        // A passphrase that does NOT start with "test-", so the test-only
1493        // bypass in `load` leaves the rate-limit check in play.
1494        let passphrase = "correct-passphrase-1234567890";
1495        let salt = EncryptionKey::generate_salt();
1496        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1497        key.save(&key_file_str, passphrase).unwrap();
1498
1499        // A wrong passphrase is rejected on its merits, and counted.
1500        assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1501
1502        // The next attempt falls inside the backoff window, so the throttle
1503        // turns it away before any verification happens. The throttle refuses
1504        // rather than sleeping, so the caller is told how long to wait instead
1505        // of having a thread parked on its behalf.
1506        // `unwrap_err` is avoided throughout: it would require `EncryptionKey`
1507        // to be `Debug`, and that type holds live key material.
1508        let start = std::time::Instant::now();
1509        let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1510            .err()
1511            .expect("an attempt inside the backoff window must be refused");
1512        assert!(
1513            throttled.contains("wait"),
1514            "expected a rate-limit refusal, got: {}",
1515            throttled
1516        );
1517        assert!(
1518            start.elapsed() < Duration::from_secs(1),
1519            "the throttle should refuse immediately rather than block the caller"
1520        );
1521
1522        // Once the 2^1-second window passes, attempts are evaluated again — the
1523        // failure that comes back is about the passphrase, not the throttle.
1524        std::thread::sleep(Duration::from_millis(2_100));
1525        let correct = EncryptionKey::load(&key_file, passphrase);
1526        assert!(
1527            correct.is_ok(),
1528            "the correct passphrase should be accepted once the window passes: {:?}",
1529            correct.as_ref().err()
1530        );
1531
1532        // Success clears the counter, so the next wrong attempt is judged on
1533        // its merits rather than being thrown out by the throttle.
1534        let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1535            .err()
1536            .expect("a wrong passphrase must still fail");
1537        assert!(
1538            !after_reset.contains("wait"),
1539            "a successful load should reset the counter, got: {}",
1540            after_reset
1541        );
1542
1543        let _ = fs::remove_file(&key_file);
1544    }
1545
1546    /// Nonces must not repeat across freshly created engines.
1547    ///
1548    /// The old construction put an engine-local counter in the top 8 bytes of
1549    /// the nonce, so every new engine — every process, every store opened —
1550    /// started again at zero and the first encryption always carried the same
1551    /// 8 leading bytes. Only 4 random bytes separated two such nonces, and a
1552    /// repeated nonce under one AES-GCM key is catastrophic. Simulate a run of
1553    /// separate processes and require the nonces to be distinct.
1554    #[test]
1555    fn test_nonces_do_not_repeat_across_engines() {
1556        let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1557
1558        let mut nonces = std::collections::HashSet::new();
1559        let mut leading_zero_runs = 0;
1560
1561        for _ in 0..64 {
1562            // A fresh engine each time, as a new process would build.
1563            let engine = EncryptionEngine::new(&key).unwrap();
1564            let blob = engine.encrypt(b"same plaintext every time").unwrap();
1565            let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1566
1567            if nonce[..8] == [0u8; 8] {
1568                leading_zero_runs += 1;
1569            }
1570            assert!(
1571                nonces.insert(nonce),
1572                "a nonce repeated across engines, which breaks AES-GCM"
1573            );
1574        }
1575
1576        // Under the old scheme every one of these would have started 0x00 * 8.
1577        assert!(
1578            leading_zero_runs <= 1,
1579            "{} of 64 nonces began with eight zero bytes, which means the \
1580             counter is resetting rather than the nonce being random",
1581            leading_zero_runs
1582        );
1583    }
1584}
1585
1586#[cfg(test)]
1587mod key_file_permission_tests {
1588    use super::*;
1589
1590    /// Saving over an existing key file must keep working.
1591    ///
1592    /// The key file is restricted to its owner before being renamed into
1593    /// place. On Windows that restriction is the read-only attribute, and
1594    /// `fs::rename` onto a read-only destination is exactly what `rotate-key`
1595    /// does — so if it failed, rotation would break on the second save.
1596    #[test]
1597    fn test_key_file_can_be_saved_over() {
1598        let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1599        let _ = fs::remove_file(&path);
1600        let path_str = path.to_string_lossy().to_string();
1601
1602        let first =
1603            EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1604        first
1605            .save(&path_str, "FirstPassphrase!123")
1606            .expect("first save should succeed");
1607
1608        let second =
1609            EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1610        second
1611            .save(&path_str, "SecondPassphrase!234")
1612            .expect("saving over an existing key file should succeed, as rotate-key does");
1613
1614        // The second key's salt should be what is on disk now.
1615        let stored = fs::read(&path).unwrap();
1616        assert_eq!(
1617            &stored[..SALT_SIZE],
1618            &[2u8; SALT_SIZE],
1619            "the rewrite should have taken effect"
1620        );
1621
1622        let _ = fs::remove_file(&path);
1623    }
1624
1625    /// The restriction has to tighten a file that already exists, not only one
1626    /// this version created. Keys written before the restriction existed sit on
1627    /// disk with whatever the umask gave them, and only a call on the load path
1628    /// ever corrects them.
1629    #[test]
1630    fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1631        let dir = tempfile::tempdir().unwrap();
1632        let path = dir.path().join("preexisting.key");
1633        fs::write(&path, b"secret").unwrap();
1634
1635        #[cfg(unix)]
1636        {
1637            use std::os::unix::fs::PermissionsExt;
1638            let mut perms = fs::metadata(&path).unwrap().permissions();
1639            perms.set_mode(0o644);
1640            fs::set_permissions(&path, perms).unwrap();
1641
1642            restrict_to_owner(&path).unwrap();
1643
1644            let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1645            assert_eq!(mode, 0o600, "group and other should have lost all access");
1646        }
1647
1648        #[cfg(windows)]
1649        restrict_to_owner(&path).unwrap();
1650
1651        // Whatever the platform, the owner must still be able to read it back —
1652        // a restriction that locks out the process that applied it is a bug.
1653        assert_eq!(fs::read(&path).unwrap(), b"secret");
1654    }
1655}