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    None
861}
862
863/// Prompt user for passphrase securely via CLI
864///
865/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > cache > interactive prompt.
866/// In non-interactive mode (default for agents), returns error if no passphrase
867/// is available from env/file/cache.
868pub fn prompt_for_passphrase(
869    repo_path: &str,
870    config: &EncryptionConfig,
871    prompt_text: &str,
872) -> Result<Zeroizing<String>, String> {
873    // Try non-interactive sources first
874    if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
875        return Ok(pass);
876    }
877
878    // Agent safety: never block on an interactive prompt when there is no TTY
879    // (the default for agents, pipes, and CI). Fail fast with remediation.
880    if !std::io::stdin().is_terminal() {
881        return Err(
882            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
883             LIT_PASSPHRASE_FILE"
884                .to_string(),
885        );
886    }
887
888    // Fall back to interactive prompt
889    rpassword::prompt_password(prompt_text)
890        .map(Zeroizing::new)
891        .map_err(|e| format!("Failed to read passphrase: {}", e))
892}
893
894/// Minimum passphrase length (NIST SP 800-63B recommendation for high security)
895const MIN_PASSPHRASE_LENGTH: usize = 16;
896
897/// Validate passphrase strength
898///
899/// Requirements:
900/// - Minimum 16 characters (NIST SP 800-63B)
901/// - At least 3 of: uppercase, lowercase, digits, special characters
902fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
903    // SECURITY: Test bypass only available in test builds (FINDING-001)
904    #[cfg(test)]
905    if passphrase.starts_with("test-") {
906        return Ok(());
907    }
908
909    if passphrase.len() < MIN_PASSPHRASE_LENGTH {
910        return Err(format!(
911            "Passphrase must be at least {} characters (recommended: 20+)",
912            MIN_PASSPHRASE_LENGTH
913        ));
914    }
915
916    // Check complexity
917    let has_upper = passphrase.chars().any(|c| c.is_uppercase());
918    let has_lower = passphrase.chars().any(|c| c.is_lowercase());
919    let has_digit = passphrase.chars().any(|c| c.is_numeric());
920    let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
921
922    let complexity_count = [has_upper, has_lower, has_digit, has_special]
923        .iter()
924        .filter(|&&x| x)
925        .count();
926
927    if complexity_count < 3 {
928        return Err(
929            "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
930                .to_string(),
931        );
932    }
933
934    Ok(())
935}
936
937/// Prompt for passphrase confirmation (for new passphrases)
938///
939/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > interactive prompt (with confirmation).
940pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
941    // Check LIT_PASSPHRASE env var
942    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
943        if !pass.is_empty() {
944            validate_passphrase_strength(&pass)?;
945            return Ok(Zeroizing::new(pass));
946        }
947    }
948
949    // Check LIT_PASSPHRASE_FILE env var
950    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
951        if let Ok(pass) = std::fs::read_to_string(&path) {
952            let pass = pass
953                .trim_end_matches('\n')
954                .trim_end_matches('\r')
955                .to_string();
956            if !pass.is_empty() {
957                validate_passphrase_strength(&pass)?;
958                return Ok(Zeroizing::new(pass));
959            }
960        }
961    }
962
963    // Agent safety: never block on an interactive prompt when there is no TTY.
964    if !std::io::stdin().is_terminal() {
965        return Err(
966            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
967             LIT_PASSPHRASE_FILE"
968                .to_string(),
969        );
970    }
971
972    // Interactive prompt with confirmation
973    let pass1 = rpassword::prompt_password(prompt_text)
974        .map_err(|e| format!("Failed to read passphrase: {}", e))?;
975
976    let pass2 = rpassword::prompt_password("Confirm passphrase: ")
977        .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
978
979    if pass1 != pass2 {
980        return Err("Passphrases do not match".to_string());
981    }
982
983    validate_passphrase_strength(&pass1)?;
984
985    Ok(Zeroizing::new(pass1))
986}
987
988/// Encryption manager for repository
989pub struct EncryptionManager {
990    config: EncryptionConfig,
991    engine: Option<EncryptionEngine>,
992    repo_path: Option<String>,
993}
994
995impl EncryptionManager {
996    /// Create new encryption manager
997    pub fn new(config: EncryptionConfig) -> Self {
998        EncryptionManager {
999            config,
1000            engine: None,
1001            repo_path: None,
1002        }
1003    }
1004
1005    /// Build a manager, initializing it from a non-interactive passphrase
1006    /// source when encryption is enabled and one is available.
1007    ///
1008    /// Every command builds its object store through `ObjectStore::new`, which
1009    /// returns `Self` rather than a `Result` and must not prompt — Lit is
1010    /// zero-prompt by design. So the passphrase comes from `LIT_PASSPHRASE`,
1011    /// `LIT_PASSPHRASE_FILE` or the cache, and nothing else.
1012    ///
1013    /// With encryption enabled and no source available the manager stays
1014    /// uninitialized on purpose: the first encrypt or decrypt then reports
1015    /// that plainly, which is a better failure than a constructor that cannot
1016    /// explain itself.
1017    pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
1018        let mut manager = EncryptionManager::new(config);
1019        if !manager.config.enabled {
1020            return manager;
1021        }
1022
1023        let repo = repo_path.to_string_lossy().to_string();
1024        let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
1025            return manager;
1026        };
1027
1028        manager.repo_path = Some(repo.clone());
1029        if let Err(e) = manager.initialize(&passphrase) {
1030            eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
1031            return manager;
1032        }
1033
1034        if manager.config.cache_timeout_secs > 0 {
1035            let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
1036            cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
1037        }
1038
1039        manager
1040    }
1041
1042    /// Whether `data` carries our encryption header.
1043    ///
1044    /// Lets a reader tell ciphertext from content written before encryption
1045    /// was switched on, so a repository part-way through migration stays
1046    /// readable. Nothing we write in the clear begins with this byte: refs hold
1047    /// hex or `ref: `, the index holds JSON.
1048    pub fn is_encrypted_payload(data: &[u8]) -> bool {
1049        data.first() == Some(&ENCRYPTION_VERSION)
1050    }
1051
1052    /// Initialize encryption with passphrase
1053    pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
1054        if !self.config.enabled {
1055            return Ok(());
1056        }
1057
1058        let expanded = shellexpand::tilde(&self.config.key_file);
1059        let key_file = Path::new(expanded.as_ref());
1060
1061        // A command opens several stores — the object store, the index, and the
1062        // pack reader behind them — and each one lands here. Deriving the key
1063        // every time means paying PBKDF2's 600,000 iterations several times
1064        // over for a single `lit status`. Reuse a key already derived in this
1065        // process for the same file and passphrase.
1066        //
1067        // The cache is memory-only and dies with the process, so it widens no
1068        // window that holding the key for the length of one command already
1069        // opens. Only successful derivations are stored, so a wrong passphrase
1070        // still goes the long way round and still meets the rate limiter.
1071        let cache_id = derived_key_id(expanded.as_ref(), passphrase);
1072        if let Some(key) = cached_derived_key(&cache_id) {
1073            self.engine = Some(EncryptionEngine::new(&key)?);
1074            return Ok(());
1075        }
1076
1077        // Load or create encryption key
1078        let key = if key_file.exists() {
1079            EncryptionKey::load(key_file, passphrase)?
1080        } else {
1081            let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1082            key.save(&self.config.key_file, passphrase)?;
1083            key
1084        };
1085
1086        let key = std::sync::Arc::new(key);
1087        remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1088
1089        // Create encryption engine
1090        self.engine = Some(EncryptionEngine::new(&key)?);
1091
1092        Ok(())
1093    }
1094
1095    /// Initialize encryption with passphrase caching support
1096    pub fn initialize_with_cache(
1097        &mut self,
1098        repo_path: &str,
1099        passphrase: Option<&str>,
1100    ) -> Result<(), String> {
1101        if !self.config.enabled {
1102            return Ok(());
1103        }
1104
1105        self.repo_path = Some(repo_path.to_string());
1106
1107        // Try to get cached passphrase first
1108        let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1109            Zeroizing::new(pass.to_string())
1110        } else if let Some(cached) = get_cached_passphrase(repo_path) {
1111            cached
1112        } else {
1113            return Err("No passphrase provided and no valid cached passphrase found".to_string());
1114        };
1115
1116        // Initialize encryption
1117        self.initialize(&actual_passphrase)?;
1118
1119        // Cache the passphrase if caching is enabled
1120        if self.config.cache_timeout_secs > 0 {
1121            let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1122            cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1123        }
1124
1125        Ok(())
1126    }
1127
1128    /// Encrypt data if encryption is enabled
1129    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1130        if !self.config.enabled {
1131            return Ok(plaintext.to_vec());
1132        }
1133
1134        match &self.engine {
1135            Some(engine) => engine.encrypt(plaintext),
1136            None => Err(
1137                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1138            ),
1139        }
1140    }
1141
1142    /// Decrypt data if encryption is enabled
1143    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1144        if !self.config.enabled {
1145            return Ok(encrypted.to_vec());
1146        }
1147
1148        match &self.engine {
1149            Some(engine) => {
1150                // Data written before encryption was switched on carries no
1151                // header of ours, so it fails here with a version number taken
1152                // from whatever byte happened to be first — 123 for the `{` of
1153                // the plaintext index, which explains nothing. Encryption
1154                // cannot be turned on for a repository that already has
1155                // content, and this is where a user finds that out.
1156                if encrypted
1157                    .first()
1158                    .is_some_and(|version| *version != ENCRYPTION_VERSION)
1159                {
1160                    return Err(
1161                        "This data has no Lit encryption header. Encryption cannot be \
1162                         enabled for a repository that already contains unencrypted \
1163                         commits — start a new encrypted repository and import into it."
1164                            .to_string(),
1165                    );
1166                }
1167                engine.decrypt(encrypted)
1168            }
1169            None => Err(
1170                "Encryption not initialized. Call initialize() with passphrase first.".to_string(),
1171            ),
1172        }
1173    }
1174
1175    /// Check if encryption is enabled
1176    pub fn is_enabled(&self) -> bool {
1177        self.config.enabled
1178    }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184
1185    /// Serializes tests that mutate the process-global passphrase cache.
1186    ///
1187    /// Several tests call [`clear_passphrase_cache`], which wipes every entry;
1188    /// running them in parallel lets one test clear another's freshly-cached
1189    /// entry, producing spurious failures. Holding this lock makes those tests
1190    /// mutually exclusive. Poisoning is recovered from since a panic in one
1191    /// test must not cascade into the others.
1192    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1193
1194    fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1195        CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1196    }
1197
1198    /// A key-file path belonging to a single test.
1199    ///
1200    /// Tests that exercise `EncryptionKey::save`/`load` write a real file, and
1201    /// the rate-limiter keys its failed-attempt tracker off that path. Pointing
1202    /// them at `~/.lit/encryption.key` therefore made them collide with one
1203    /// another — and, since they delete the file to start clean, destroyed the
1204    /// operator's real key on any run that included them. A per-test path in
1205    /// the temp directory isolates the file and the tracker together.
1206    fn test_key_path(label: &str) -> std::path::PathBuf {
1207        static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1208        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1209        let path = std::env::temp_dir().join(format!(
1210            "lit_enc_test_{}_{}_{}.key",
1211            std::process::id(),
1212            label,
1213            n
1214        ));
1215        let _ = fs::remove_file(&path);
1216        path
1217    }
1218
1219    #[test]
1220    fn test_key_derivation() {
1221        let passphrase = "test-passphrase-12345";
1222        let salt = EncryptionKey::generate_salt();
1223
1224        let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1225        let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1226
1227        // Same passphrase and salt should produce same key
1228        assert_eq!(key1.as_bytes(), key2.as_bytes());
1229    }
1230
1231    #[test]
1232    fn test_encryption_decryption() {
1233        let passphrase = "test-secure-passphrase";
1234        let salt = EncryptionKey::generate_salt();
1235        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1236
1237        let engine = EncryptionEngine::new(&key).unwrap();
1238
1239        let plaintext = b"Hello, this is secret data!";
1240
1241        // Encrypt
1242        let encrypted = engine.encrypt(plaintext).unwrap();
1243
1244        // Verify encrypted data is different
1245        assert_ne!(encrypted.as_slice(), plaintext);
1246
1247        // Decrypt
1248        let decrypted = engine.decrypt(&encrypted).unwrap();
1249
1250        // Verify original data restored
1251        assert_eq!(decrypted.as_slice(), plaintext);
1252    }
1253
1254    #[test]
1255    fn test_encryption_nonce_randomness() {
1256        let passphrase = "test-passphrase";
1257        let salt = EncryptionKey::generate_salt();
1258        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1259
1260        let engine = EncryptionEngine::new(&key).unwrap();
1261
1262        let plaintext = b"Same data";
1263
1264        // Encrypt same data twice
1265        let encrypted1 = engine.encrypt(plaintext).unwrap();
1266        let encrypted2 = engine.encrypt(plaintext).unwrap();
1267
1268        // Should produce different ciphertexts (different nonces)
1269        assert_ne!(encrypted1, encrypted2);
1270
1271        // But both should decrypt to same plaintext
1272        assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1273        assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1274    }
1275
1276    #[test]
1277    fn test_tampering_detection() {
1278        let passphrase = "test-passphrase";
1279        let salt = EncryptionKey::generate_salt();
1280        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1281
1282        let engine = EncryptionEngine::new(&key).unwrap();
1283
1284        let plaintext = b"Secret data";
1285        let mut encrypted = engine.encrypt(plaintext).unwrap();
1286
1287        // Tamper with ciphertext
1288        let len = encrypted.len();
1289        encrypted[len - 1] ^= 0x01;
1290
1291        // Decryption should fail due to authentication tag mismatch
1292        assert!(engine.decrypt(&encrypted).is_err());
1293    }
1294
1295    #[test]
1296    fn test_encryption_manager_disabled() {
1297        let config = EncryptionConfig {
1298            enabled: false,
1299            ..Default::default()
1300        };
1301
1302        let manager = EncryptionManager::new(config);
1303
1304        let data = b"Some data";
1305
1306        // When disabled, should return data as-is
1307        assert_eq!(manager.encrypt(data).unwrap(), data);
1308        assert_eq!(manager.decrypt(data).unwrap(), data);
1309    }
1310
1311    #[test]
1312    fn test_passphrase_caching() {
1313        let _guard = cache_test_guard();
1314        let repo_path = "/tmp/test-repo";
1315        let passphrase = "cache-test-passphrase".to_string();
1316
1317        // Clear cache first
1318        clear_passphrase_cache();
1319
1320        // Should return None when not cached
1321        assert!(get_cached_passphrase(repo_path).is_none());
1322
1323        // Cache passphrase with 5 second timeout
1324        cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1325
1326        // Should retrieve cached passphrase
1327        assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1328
1329        // Clear specific entry
1330        clear_cached_passphrase(repo_path);
1331        assert!(get_cached_passphrase(repo_path).is_none());
1332    }
1333
1334    #[test]
1335    fn test_passphrase_cache_expiration() {
1336        let _guard = cache_test_guard();
1337        let repo_path = "/tmp/test-repo-expire";
1338        let passphrase = "expire-test".to_string();
1339
1340        clear_passphrase_cache();
1341
1342        // Cache with a short timeout, then wait well past it and assert the
1343        // entry was evicted. This test deliberately avoids asserting immediate
1344        // availability — that behavior is covered by `test_passphrase_caching`,
1345        // and a tight "available right now" check would race the timeout under
1346        // heavy parallel CPU load. Asserting only expiration is robust: more
1347        // load can only make the entry *more* expired, never less.
1348        cache_passphrase(
1349            repo_path,
1350            passphrase.clone(),
1351            Some(Duration::from_millis(200)),
1352        );
1353
1354        std::thread::sleep(Duration::from_millis(600));
1355
1356        // Should be expired and removed
1357        assert!(get_cached_passphrase(repo_path).is_none());
1358    }
1359
1360    #[test]
1361    fn test_passphrase_cache_multiple_repos() {
1362        let _guard = cache_test_guard();
1363        let repo1 = "/tmp/multi-cache-repo1";
1364        let repo2 = "/tmp/multi-cache-repo2";
1365        let pass1 = "password1".to_string();
1366        let pass2 = "password2".to_string();
1367
1368        clear_passphrase_cache();
1369
1370        // Cache different passphrases for different repos
1371        cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1372        cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1373
1374        // Should retrieve correct passphrase for each repo
1375        assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1376        assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1377    }
1378
1379    #[test]
1380    fn test_encryption_manager_with_cache() {
1381        use std::env;
1382
1383        let _guard = cache_test_guard();
1384
1385        let key_file = test_key_path("manager_cache");
1386
1387        let temp_dir = env::temp_dir();
1388        let repo_path = temp_dir.join("test-cache-manager");
1389        let repo_str = repo_path.to_str().unwrap();
1390
1391        clear_passphrase_cache();
1392
1393        let config = EncryptionConfig {
1394            enabled: true,
1395            key_file: key_file.to_string_lossy().into_owned(),
1396            cache_timeout_secs: 300, // 5 minutes
1397            ..Default::default()
1398        };
1399
1400        let mut manager = EncryptionManager::new(config);
1401        let passphrase = "test-cache-manager-pass";
1402
1403        // Initialize with cache
1404        manager
1405            .initialize_with_cache(repo_str, Some(passphrase))
1406            .unwrap();
1407
1408        // Passphrase should be cached
1409        assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1410
1411        // Should be able to initialize again without providing passphrase
1412        let mut manager2 = EncryptionManager::new(manager.config.clone());
1413        manager2.initialize_with_cache(repo_str, None).unwrap();
1414
1415        // Clear cache for cleanup
1416        clear_passphrase_cache();
1417        let _ = fs::remove_file(&key_file);
1418    }
1419
1420    /// The throttle has to outlive the process that recorded the attempts.
1421    ///
1422    /// Every `lit` command is a new process. While the counter lived in a
1423    /// `static`, each one started at zero: the exponential backoff never grew
1424    /// past its first step and the five-attempt lockout could not be reached at
1425    /// all by a script that reran the binary, which is the case it exists for.
1426    /// Reading the state back from disk is what a second process does, so that
1427    /// is what this asserts.
1428    #[test]
1429    fn test_throttle_state_outlives_the_process() {
1430        let dir = tempfile::tempdir().unwrap();
1431        let key_path = dir.path().join("outlives.key");
1432        let key = key_path.to_string_lossy().into_owned();
1433
1434        for _ in 0..5 {
1435            record_failed_attempt(&key);
1436        }
1437
1438        // What a freshly started process would see.
1439        let seen = load_throttle(&key);
1440        assert_eq!(seen.count, 5, "the count should have survived on disk");
1441        assert!(
1442            seen.lockout_until.is_some(),
1443            "five failures should have produced a lockout a new process can see"
1444        );
1445
1446        // And it should actually refuse, rather than merely recording a number.
1447        assert!(
1448            check_rate_limit(&key).is_err(),
1449            "a locked-out key should be refused"
1450        );
1451
1452        // A correct passphrase clears it, so an ordinary typo is not sticky.
1453        clear_failed_attempts(&key);
1454        assert_eq!(load_throttle(&key).count, 0);
1455        assert!(check_rate_limit(&key).is_ok());
1456    }
1457
1458    /// Corrupt or unreadable state must not lock the owner out of their own
1459    /// repository — the throttle slows guessing, it is not an access control.
1460    #[test]
1461    fn test_unreadable_throttle_state_is_treated_as_a_clean_slate() {
1462        let dir = tempfile::tempdir().unwrap();
1463        let key_path = dir.path().join("corrupt.key");
1464        let key = key_path.to_string_lossy().into_owned();
1465
1466        fs::write(throttle_state_path(&key), b"this is not json").unwrap();
1467
1468        assert_eq!(load_throttle(&key).count, 0);
1469        assert!(check_rate_limit(&key).is_ok());
1470    }
1471
1472    /// Exercises the brute-force throttle on `EncryptionKey::load`.
1473    ///
1474    /// Ignored for runtime, not correctness: each attempt that gets as far as
1475    /// verification runs PBKDF2 at 600k iterations, which costs seconds in an
1476    /// unoptimized build. Run it with `cargo test -- --ignored`.
1477    #[test]
1478    #[ignore]
1479    fn test_rate_limiting() {
1480        let key_file = test_key_path("rate_limiting");
1481        let key_file_str = key_file.to_string_lossy().into_owned();
1482
1483        // A passphrase that does NOT start with "test-", so the test-only
1484        // bypass in `load` leaves the rate-limit check in play.
1485        let passphrase = "correct-passphrase-1234567890";
1486        let salt = EncryptionKey::generate_salt();
1487        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1488        key.save(&key_file_str, passphrase).unwrap();
1489
1490        // A wrong passphrase is rejected on its merits, and counted.
1491        assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1492
1493        // The next attempt falls inside the backoff window, so the throttle
1494        // turns it away before any verification happens. The throttle refuses
1495        // rather than sleeping, so the caller is told how long to wait instead
1496        // of having a thread parked on its behalf.
1497        // `unwrap_err` is avoided throughout: it would require `EncryptionKey`
1498        // to be `Debug`, and that type holds live key material.
1499        let start = std::time::Instant::now();
1500        let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1501            .err()
1502            .expect("an attempt inside the backoff window must be refused");
1503        assert!(
1504            throttled.contains("wait"),
1505            "expected a rate-limit refusal, got: {}",
1506            throttled
1507        );
1508        assert!(
1509            start.elapsed() < Duration::from_secs(1),
1510            "the throttle should refuse immediately rather than block the caller"
1511        );
1512
1513        // Once the 2^1-second window passes, attempts are evaluated again — the
1514        // failure that comes back is about the passphrase, not the throttle.
1515        std::thread::sleep(Duration::from_millis(2_100));
1516        let correct = EncryptionKey::load(&key_file, passphrase);
1517        assert!(
1518            correct.is_ok(),
1519            "the correct passphrase should be accepted once the window passes: {:?}",
1520            correct.as_ref().err()
1521        );
1522
1523        // Success clears the counter, so the next wrong attempt is judged on
1524        // its merits rather than being thrown out by the throttle.
1525        let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1526            .err()
1527            .expect("a wrong passphrase must still fail");
1528        assert!(
1529            !after_reset.contains("wait"),
1530            "a successful load should reset the counter, got: {}",
1531            after_reset
1532        );
1533
1534        let _ = fs::remove_file(&key_file);
1535    }
1536
1537    /// Nonces must not repeat across freshly created engines.
1538    ///
1539    /// The old construction put an engine-local counter in the top 8 bytes of
1540    /// the nonce, so every new engine — every process, every store opened —
1541    /// started again at zero and the first encryption always carried the same
1542    /// 8 leading bytes. Only 4 random bytes separated two such nonces, and a
1543    /// repeated nonce under one AES-GCM key is catastrophic. Simulate a run of
1544    /// separate processes and require the nonces to be distinct.
1545    #[test]
1546    fn test_nonces_do_not_repeat_across_engines() {
1547        let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1548
1549        let mut nonces = std::collections::HashSet::new();
1550        let mut leading_zero_runs = 0;
1551
1552        for _ in 0..64 {
1553            // A fresh engine each time, as a new process would build.
1554            let engine = EncryptionEngine::new(&key).unwrap();
1555            let blob = engine.encrypt(b"same plaintext every time").unwrap();
1556            let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1557
1558            if nonce[..8] == [0u8; 8] {
1559                leading_zero_runs += 1;
1560            }
1561            assert!(
1562                nonces.insert(nonce),
1563                "a nonce repeated across engines, which breaks AES-GCM"
1564            );
1565        }
1566
1567        // Under the old scheme every one of these would have started 0x00 * 8.
1568        assert!(
1569            leading_zero_runs <= 1,
1570            "{} of 64 nonces began with eight zero bytes, which means the \
1571             counter is resetting rather than the nonce being random",
1572            leading_zero_runs
1573        );
1574    }
1575}
1576
1577#[cfg(test)]
1578mod key_file_permission_tests {
1579    use super::*;
1580
1581    /// Saving over an existing key file must keep working.
1582    ///
1583    /// The key file is restricted to its owner before being renamed into
1584    /// place. On Windows that restriction is the read-only attribute, and
1585    /// `fs::rename` onto a read-only destination is exactly what `rotate-key`
1586    /// does — so if it failed, rotation would break on the second save.
1587    #[test]
1588    fn test_key_file_can_be_saved_over() {
1589        let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1590        let _ = fs::remove_file(&path);
1591        let path_str = path.to_string_lossy().to_string();
1592
1593        let first =
1594            EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1595        first
1596            .save(&path_str, "FirstPassphrase!123")
1597            .expect("first save should succeed");
1598
1599        let second =
1600            EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1601        second
1602            .save(&path_str, "SecondPassphrase!234")
1603            .expect("saving over an existing key file should succeed, as rotate-key does");
1604
1605        // The second key's salt should be what is on disk now.
1606        let stored = fs::read(&path).unwrap();
1607        assert_eq!(
1608            &stored[..SALT_SIZE],
1609            &[2u8; SALT_SIZE],
1610            "the rewrite should have taken effect"
1611        );
1612
1613        let _ = fs::remove_file(&path);
1614    }
1615
1616    /// The restriction has to tighten a file that already exists, not only one
1617    /// this version created. Keys written before the restriction existed sit on
1618    /// disk with whatever the umask gave them, and only a call on the load path
1619    /// ever corrects them.
1620    #[test]
1621    fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1622        let dir = tempfile::tempdir().unwrap();
1623        let path = dir.path().join("preexisting.key");
1624        fs::write(&path, b"secret").unwrap();
1625
1626        #[cfg(unix)]
1627        {
1628            use std::os::unix::fs::PermissionsExt;
1629            let mut perms = fs::metadata(&path).unwrap().permissions();
1630            perms.set_mode(0o644);
1631            fs::set_permissions(&path, perms).unwrap();
1632
1633            restrict_to_owner(&path).unwrap();
1634
1635            let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1636            assert_eq!(mode, 0o600, "group and other should have lost all access");
1637        }
1638
1639        #[cfg(windows)]
1640        restrict_to_owner(&path).unwrap();
1641
1642        // Whatever the platform, the owner must still be able to read it back —
1643        // a restriction that locks out the process that applied it is a bug.
1644        assert_eq!(fs::read(&path).unwrap(), b"secret");
1645    }
1646}