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    ///
84    /// Empty means "not chosen yet"; [`EncryptionConfig::load`] fills it in
85    /// with a per-repository path. Nothing downstream ever sees it empty.
86    #[serde(default)]
87    pub key_file: String,
88    /// FIPS 140-3 mode (strict algorithm compliance)
89    pub fips_mode: bool,
90    /// Passphrase cache timeout in seconds (0 to disable caching)
91    #[serde(default = "default_cache_timeout")]
92    pub cache_timeout_secs: u64,
93}
94
95fn default_cache_timeout() -> u64 {
96    300 // 5 minutes
97}
98
99impl Default for EncryptionConfig {
100    fn default() -> Self {
101        EncryptionConfig {
102            enabled: false,
103            // Left unset on purpose: only `load` knows which repository this
104            // belongs to, and the answer is per repository.
105            key_file: String::new(),
106            fips_mode: true,
107            cache_timeout_secs: default_cache_timeout(),
108        }
109    }
110}
111
112/// The key file a repository gets when `encryption.toml` does not name one.
113///
114/// One file per repository, under the user's home rather than inside `.lit`.
115/// Both halves of that matter:
116///
117/// - *Per repository*, because a single shared key file means the second
118///   repository to be initialised has to reuse the first one's passphrase or
119///   fail — `~/.lit/encryption.key` was the documented value everywhere, so in
120///   practice every repository collided on one file.
121/// - *Outside the working tree*, because a key file that travelled with the
122///   repository would ship the salt and the passphrase-verification hash
123///   alongside the ciphertext they protect, turning any copied or backed-up
124///   repository into a self-contained offline guessing target.
125///
126/// The name carries the repository's directory name so the files are
127/// recognisable, and a digest of its absolute path so two directories with the
128/// same name do not collide. The path is only the *starting* binding: `load`
129/// writes the result back into `encryption.toml`, so moving a repository keeps
130/// it pointed at the same key.
131pub fn default_key_file(repo_path: &Path) -> String {
132    use sha2::{Digest, Sha256};
133
134    let absolute = fs::canonicalize(repo_path).unwrap_or_else(|_| repo_path.to_path_buf());
135
136    let label: String = absolute
137        .file_name()
138        .map(|n| n.to_string_lossy().to_string())
139        .unwrap_or_else(|| "repo".to_string())
140        .chars()
141        .map(|c| {
142            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
143                c
144            } else {
145                '-'
146            }
147        })
148        .collect();
149    let label = label.trim_matches('-').to_string();
150    let label = if label.is_empty() {
151        "repo".to_string()
152    } else {
153        label
154    };
155
156    let mut hasher = Sha256::new();
157    hasher.update(absolute.to_string_lossy().as_bytes());
158    let digest = hex::encode(hasher.finalize());
159
160    format!("~/.lit/keys/{}-{}.key", label, &digest[..12])
161}
162
163/// The legacy key path every doc and example used before key files became
164/// per-repository. Still honoured when a config names it explicitly.
165const SHARED_KEY_FILE: &str = "~/.lit/encryption.key";
166
167/// Add the one explanation a rejected passphrase usually needs.
168///
169/// A key file shared between repositories can only ever hold one passphrase, so
170/// the second repository to use it reports "Invalid passphrase" for a
171/// passphrase that is perfectly correct — for the other repository. That was
172/// the whole shape of the shared-key-file problem, and it is worth naming at
173/// the point it bites rather than leaving it to be rediscovered.
174fn explain_key_failure(error: String, key_file: &Path) -> String {
175    let is_shared = fs::canonicalize(shellexpand::tilde(SHARED_KEY_FILE).as_ref())
176        .ok()
177        .zip(fs::canonicalize(key_file).ok())
178        .is_some_and(|(shared, actual)| shared == actual);
179
180    if !is_shared || !error.contains("Invalid passphrase") {
181        return error;
182    }
183
184    format!(
185        "{error}. This repository uses the shared key file {SHARED_KEY_FILE}, \
186         which holds a single passphrase for every repository configured to use \
187         it — so another repository's passphrase will be rejected here. Remove \
188         the key_file line from .lit/encryption.toml to get a key file of this \
189         repository's own."
190    )
191}
192
193impl EncryptionConfig {
194    /// Load configuration from repository
195    pub fn load(repo_path: &Path) -> Result<Self, String> {
196        let config_path = repo_path.join(".lit").join("encryption.toml");
197
198        if !config_path.exists() {
199            return Ok(EncryptionConfig {
200                key_file: default_key_file(repo_path),
201                ..Self::default()
202            });
203        }
204
205        let content = fs::read_to_string(&config_path)
206            .map_err(|e| format!("Failed to read encryption config: {}", e))?;
207
208        let mut config: Self = toml::from_str(&content)
209            .map_err(|e| format!("Failed to parse encryption config: {}", e))?;
210
211        if config.key_file.trim().is_empty() {
212            config.key_file = default_key_file(repo_path);
213
214            // Record the choice, but only once it matters. The derivation reads
215            // the repository's absolute path, so a repository that is moved
216            // would otherwise resolve to a different file and find no key
217            // there — which is worth writing down. Doing it for a repository
218            // that has encryption switched off would mean every command
219            // rewriting a config file the user is not using.
220            //
221            // Best effort even then: a checkout on read-only media must stay
222            // usable, and the path resolves the same way as long as it stays
223            // put.
224            if config.enabled {
225                let _ = config.save(repo_path);
226            }
227        }
228
229        Ok(config)
230    }
231
232    /// Save configuration to repository
233    pub fn save(&self, repo_path: &Path) -> Result<(), String> {
234        let config_path = repo_path.join(".lit").join("encryption.toml");
235
236        let content = toml::to_string_pretty(self)
237            .map_err(|e| format!("Failed to serialize encryption config: {}", e))?;
238
239        fs::write(&config_path, content)
240            .map_err(|e| format!("Failed to write encryption config: {}", e))
241    }
242}
243
244/// Keep a file readable only by its owner.
245///
246/// Mode 0600 on Unix; on Windows a DACL granting the current user alone, which
247/// is what closes finding I-1 in docs/SECURITY_AUDIT.md. Both are real
248/// restrictions on reading, not just writing.
249pub(crate) fn restrict_to_owner(path: &Path) -> Result<(), String> {
250    #[cfg(unix)]
251    {
252        use std::os::unix::fs::PermissionsExt;
253        let mut perms = fs::metadata(path)
254            .map_err(|e| format!("Failed to read permissions: {}", e))?
255            .permissions();
256        perms.set_mode(0o600);
257        fs::set_permissions(path, perms)
258            .map_err(|e| format!("Failed to restrict permissions: {}", e))?;
259    }
260
261    #[cfg(windows)]
262    windows_restrict_to_owner(path)?;
263
264    Ok(())
265}
266
267/// Keep a directory listable only by its owner.
268///
269/// Same intent as [`restrict_to_owner`], but a directory needs the execute bit
270/// to be traversable at all, so 0600 would lock the owner out of their own
271/// keys. `~/.lit` and `~/.lit/keys` are created by us and hold nothing another
272/// user has any business enumerating — the key files themselves are restricted,
273/// but their *names* say which repositories exist and where.
274pub(crate) fn restrict_dir_to_owner(path: &Path) -> Result<(), String> {
275    #[cfg(unix)]
276    {
277        use std::os::unix::fs::PermissionsExt;
278        let mut perms = fs::metadata(path)
279            .map_err(|e| format!("Failed to read directory permissions: {}", e))?
280            .permissions();
281        perms.set_mode(0o700);
282        fs::set_permissions(path, perms)
283            .map_err(|e| format!("Failed to restrict directory permissions: {}", e))?;
284    }
285
286    #[cfg(windows)]
287    windows_restrict_to_owner(path)?;
288
289    Ok(())
290}
291
292/// Replace a file's DACL with one granting only the current user.
293///
294/// This is what closes finding I-1 in docs/SECURITY_AUDIT.md. The read-only
295/// attribute that stood here before stops writes and does nothing about reads,
296/// so any local account could read the file; Windows needs an explicit ACL.
297///
298/// The new DACL is marked protected, which detaches it from the parent
299/// directory's inherited entries — otherwise an inherited "Users: Read" would
300/// survive and the restriction would be for nothing.
301#[cfg(windows)]
302fn windows_restrict_to_owner(path: &Path) -> Result<(), String> {
303    use std::os::windows::ffi::OsStrExt;
304    use windows::core::PWSTR;
305    use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
306    use windows::Win32::Security::Authorization::{
307        SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, SE_FILE_OBJECT,
308        TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
309    };
310    use windows::Win32::Security::{
311        GetTokenInformation, TokenUser, ACL, DACL_SECURITY_INFORMATION, NO_INHERITANCE,
312        PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER,
313    };
314    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
315
316    // Win32 wants a NUL-terminated wide string.
317    let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
318    wide.push(0);
319
320    unsafe {
321        // The SID of whoever is running this.
322        let mut token = HANDLE::default();
323        OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)
324            .map_err(|e| format!("Failed to open process token: {}", e))?;
325
326        let mut needed = 0u32;
327        let _ = GetTokenInformation(token, TokenUser, None, 0, &mut needed);
328        let mut buffer = vec![0u8; needed as usize];
329        let info_result = GetTokenInformation(
330            token,
331            TokenUser,
332            Some(buffer.as_mut_ptr() as *mut _),
333            needed,
334            &mut needed,
335        );
336        let _ = CloseHandle(token);
337        info_result.map_err(|e| format!("Failed to read token user: {}", e))?;
338
339        let user_sid: PSID = (*(buffer.as_ptr() as *const TOKEN_USER)).User.Sid;
340
341        // One entry: this user, full control, not inherited by anything.
342        let access = EXPLICIT_ACCESS_W {
343            grfAccessPermissions: 0x001F_01FF, // FILE_ALL_ACCESS
344            grfAccessMode: SET_ACCESS,
345            grfInheritance: NO_INHERITANCE,
346            Trustee: TRUSTEE_W {
347                pMultipleTrustee: std::ptr::null_mut(),
348                MultipleTrusteeOperation: Default::default(),
349                TrusteeForm: TRUSTEE_IS_SID,
350                TrusteeType: TRUSTEE_IS_USER,
351                ptstrName: PWSTR(user_sid.0 as *mut u16),
352            },
353        };
354
355        let mut acl: *mut ACL = std::ptr::null_mut();
356        let entries = [access];
357        let status = SetEntriesInAclW(Some(&entries), None, &mut acl);
358        if status.is_err() {
359            return Err(format!("Failed to build ACL: {:?}", status));
360        }
361
362        // PROTECTED detaches the file from inherited entries; without it the
363        // parent directory's grants would remain in force.
364        let status = SetNamedSecurityInfoW(
365            PWSTR(wide.as_mut_ptr()),
366            SE_FILE_OBJECT,
367            DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
368            PSID::default(),
369            PSID::default(),
370            Some(acl),
371            None,
372        );
373
374        if !acl.is_null() {
375            let _ = LocalFree(HLOCAL(acl as *mut _));
376        }
377
378        if status.is_err() {
379            return Err(format!("Failed to set file DACL: {:?}", status));
380        }
381
382        let _ = PSECURITY_DESCRIPTOR::default();
383    }
384
385    Ok(())
386}
387
388/// Let a file be replaced by a rename, undoing what `restrict_to_owner` set.
389///
390/// Only Windows needs this: it refuses to rename onto a read-only file, and
391/// the restriction applied on the previous save is exactly that. A missing
392/// file is fine — there is nothing to clear.
393fn allow_replacement(path: &Path) -> Result<(), String> {
394    #[cfg(windows)]
395    {
396        if path.exists() {
397            let mut perms = fs::metadata(path)
398                .map_err(|e| format!("Failed to read permissions: {}", e))?
399                .permissions();
400            // Clippy warns because clearing read-only on Unix makes a file
401            // world-writable. This block is Windows-only, where the attribute
402            // is not a permission at all and clearing it is what allows the
403            // replacing rename.
404            #[allow(clippy::permissions_set_readonly_false)]
405            perms.set_readonly(false);
406            fs::set_permissions(path, perms)
407                .map_err(|e| format!("Failed to clear read-only attribute: {}", e))?;
408        }
409    }
410
411    #[cfg(not(windows))]
412    let _ = path;
413
414    Ok(())
415}
416
417/// Identify a derived key by the file it came from and the passphrase that
418/// unlocked it, without keeping the passphrase around.
419///
420/// Both parts matter: the file alone would hand back the wrong key after a
421/// `rotate-key` within one process.
422fn derived_key_id(key_file: &str, passphrase: &str) -> String {
423    use sha3::{Digest, Sha3_256};
424    let mut hasher = Sha3_256::new();
425    hasher.update(key_file.as_bytes());
426    hasher.update([0u8]); // keep the two fields from running together
427    hasher.update(passphrase.as_bytes());
428    hex::encode(hasher.finalize())
429}
430
431/// A key already derived in this process, if there is one.
432fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
433    DERIVED_KEYS.lock().ok()?.get(id).cloned()
434}
435
436/// Remember a successfully derived key for the life of the process.
437fn remember_derived_key(id: String, key: std::sync::Arc<EncryptionKey>) {
438    if let Ok(mut keys) = DERIVED_KEYS.lock() {
439        keys.insert(id, key);
440    }
441}
442
443/// Forget every key derived so far in this process.
444///
445/// The cache is consulted before the key file is even looked at, so anything
446/// that changes what the key file means — rotating it, moving it, losing it —
447/// has to drop the cache or the process keeps using the key it derived earlier.
448pub fn clear_derived_key_cache() {
449    if let Ok(mut keys) = DERIVED_KEYS.lock() {
450        keys.clear();
451    }
452}
453
454/// Where the failed-attempt count for a key file is kept between runs.
455fn throttle_state_path(repo_path: &str) -> std::path::PathBuf {
456    std::path::PathBuf::from(format!("{}.throttle", repo_path))
457}
458
459/// The throttle state as it is written to disk. Times are Unix seconds; a
460/// `SystemTime` has no stable serialized form worth depending on here.
461#[derive(Serialize, Deserialize, Default)]
462struct PersistedThrottle {
463    count: u32,
464    last_attempt_secs: u64,
465    lockout_until_secs: Option<u64>,
466}
467
468fn to_unix(t: SystemTime) -> u64 {
469    t.duration_since(SystemTime::UNIX_EPOCH)
470        .map(|d| d.as_secs())
471        .unwrap_or(0)
472}
473
474fn from_unix(secs: u64) -> SystemTime {
475    SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
476}
477
478/// Read the stored attempt count, treating anything unreadable as a clean
479/// slate. A corrupt or missing file must not lock a legitimate user out of
480/// their own repository — the throttle exists to slow guessing, not to become
481/// a way of denying access.
482fn load_throttle(repo_path: &str) -> FailedAttemptTracker {
483    let stored: Option<PersistedThrottle> = fs::read(throttle_state_path(repo_path))
484        .ok()
485        .and_then(|raw| serde_json::from_slice(&raw).ok());
486
487    match stored {
488        Some(s) => FailedAttemptTracker {
489            count: s.count,
490            last_attempt: from_unix(s.last_attempt_secs),
491            lockout_until: s.lockout_until_secs.map(from_unix),
492        },
493        None => FailedAttemptTracker {
494            count: 0,
495            last_attempt: SystemTime::now(),
496            lockout_until: None,
497        },
498    }
499}
500
501/// Persist the attempt count so the next process sees it.
502///
503/// Best-effort: a repository on read-only media should still be usable, and
504/// failing the operation because the throttle could not be written would turn
505/// a hardening measure into an outage.
506fn store_throttle(repo_path: &str, tracker: &FailedAttemptTracker) {
507    let state = PersistedThrottle {
508        count: tracker.count,
509        last_attempt_secs: to_unix(tracker.last_attempt),
510        lockout_until_secs: tracker.lockout_until.map(to_unix),
511    };
512
513    let path = throttle_state_path(repo_path);
514    if let Ok(raw) = serde_json::to_vec(&state) {
515        if fs::write(&path, raw).is_ok() {
516            // The file says how many times someone has recently failed to open
517            // this key, which is worth no more exposure than the key itself.
518            let _ = restrict_to_owner(&path);
519        }
520    }
521}
522
523/// Check rate limit for passphrase attempts
524/// Returns Ok(()) if attempt is allowed, Err with message if rate limited
525///
526/// The count lives on disk rather than in this process. Every `lit` command is
527/// a new process, so an in-memory counter starts at zero for each one: a script
528/// that reruns the binary was never slowed by the backoff and never reached the
529/// five-attempt lockout at all, which is the case the throttle exists for.
530///
531/// This raises the cost of guessing; it does not stop an attacker who can
532/// delete the state file. That is the same directory as the key file, so such
533/// an attacker is already inside the boundary the throttle assumes — PBKDF2 at
534/// 600k iterations remains the defence that does not depend on that assumption.
535fn check_rate_limit(repo_path: &str) -> Result<(), String> {
536    let _serialize = FAILED_ATTEMPTS
537        .lock()
538        .map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
539    let mut tracker = load_throttle(repo_path);
540
541    // Check if currently locked out
542    if let Some(lockout) = tracker.lockout_until {
543        if SystemTime::now() < lockout {
544            let remaining = lockout
545                .duration_since(SystemTime::now())
546                .unwrap_or(Duration::from_secs(0));
547            return Err(format!(
548                "Too many failed attempts. Please wait {} seconds before trying again.",
549                remaining.as_secs()
550            ));
551        }
552        // Lockout expired, reset counter
553        tracker.lockout_until = None;
554        tracker.count = 0;
555        store_throttle(repo_path, &tracker);
556    }
557
558    // Apply exponential backoff: 2^n seconds (max 32 seconds for n=5)
559    if tracker.count > 0 {
560        let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
561        if let Ok(elapsed) = tracker.last_attempt.elapsed() {
562            if elapsed < delay {
563                let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
564                return Err(format!(
565                    "Please wait {} seconds between passphrase attempts.",
566                    remaining
567                ));
568            }
569        }
570    }
571
572    Ok(())
573}
574
575/// Record a failed passphrase attempt
576fn record_failed_attempt(repo_path: &str) {
577    let Ok(_serialize) = FAILED_ATTEMPTS.lock() else {
578        return;
579    };
580    let mut tracker = load_throttle(repo_path);
581
582    tracker.count += 1;
583    tracker.last_attempt = SystemTime::now();
584
585    // Lock out for 5 minutes after 5 failed attempts
586    if tracker.count >= 5 {
587        tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
588        eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
589    }
590
591    store_throttle(repo_path, &tracker);
592}
593
594/// Clear failed attempt counter (called on successful authentication)
595fn clear_failed_attempts(repo_path: &str) {
596    if let Ok(_serialize) = FAILED_ATTEMPTS.lock() {
597        // A correct passphrase clears the record, so an ordinary typo costs a
598        // few seconds and nothing more once the user gets it right.
599        let _ = fs::remove_file(throttle_state_path(repo_path));
600    }
601}
602
603/// Secure encryption key with automatic zeroization
604#[derive(ZeroizeOnDrop)]
605#[allow(unused_assignments)]
606pub struct EncryptionKey {
607    key_bytes: [u8; KEY_SIZE],
608    /// Salt used to derive this key (needed for saving)
609    #[zeroize(skip)]
610    salt: [u8; SALT_SIZE],
611}
612
613impl EncryptionKey {
614    /// Derive key from passphrase using PBKDF2-HMAC-SHA512
615    pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
616        // SECURITY: Test bypass only available in test builds (FINDING-001)
617        #[cfg(not(test))]
618        validate_passphrase_strength(passphrase)?;
619        #[cfg(test)]
620        if !passphrase.starts_with("test-") {
621            validate_passphrase_strength(passphrase)?;
622        }
623
624        if salt.len() != SALT_SIZE {
625            return Err(format!(
626                "Invalid salt size: expected {}, got {}",
627                SALT_SIZE,
628                salt.len()
629            ));
630        }
631
632        let mut key_bytes = [0u8; KEY_SIZE];
633        pbkdf2_hmac::<Sha512>(
634            passphrase.as_bytes(),
635            salt,
636            PBKDF2_ITERATIONS,
637            &mut key_bytes,
638        );
639
640        let mut salt_array = [0u8; SALT_SIZE];
641        salt_array.copy_from_slice(salt);
642
643        Ok(EncryptionKey {
644            key_bytes,
645            salt: salt_array,
646        })
647    }
648
649    /// Generate a random salt for key derivation
650    pub fn generate_salt() -> [u8; SALT_SIZE] {
651        use aes_gcm::aead::rand_core::RngCore;
652        let mut salt = [0u8; SALT_SIZE];
653        OsRng.fill_bytes(&mut salt);
654        salt
655    }
656
657    /// Load key from encrypted key file
658    /// SECURITY: Verifies passphrase using stored hash (constant-time comparison)
659    /// SECURITY: Rate limiting prevents brute force attacks
660    pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
661        // SECURITY: Rate limit check — test bypass only in test builds (FINDING-001)
662        let key_file_str = key_file.to_string_lossy().to_string();
663        #[cfg(not(test))]
664        check_rate_limit(&key_file_str)?;
665        #[cfg(test)]
666        if !passphrase.starts_with("test-") {
667            check_rate_limit(&key_file_str)?;
668        }
669
670        if !key_file.exists() {
671            return Err(
672                "Encryption key file not found. Initialize repository with encryption first."
673                    .to_string(),
674            );
675        }
676
677        // Re-apply on load, not only at creation. The salt in this file is what
678        // an offline brute force of the passphrase needs, and a key file written
679        // before the restriction existed keeps its inherited ACL for the life of
680        // the installation otherwise — the one on this machine dates to March.
681        restrict_to_owner(key_file)?;
682
683        let encrypted_data =
684            fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
685
686        if encrypted_data.len() < SALT_SIZE + 1 {
687            return Err("Invalid key file format (too short)".to_string());
688        }
689
690        // Extract components
691        let salt = &encrypted_data[0..SALT_SIZE];
692        let version = encrypted_data[SALT_SIZE];
693
694        if version != ENCRYPTION_VERSION {
695            return Err(format!("Unsupported key file version: {}", version));
696        }
697
698        // Check if old format (no verification hash) or new format
699        if encrypted_data.len() == SALT_SIZE + 1 {
700            // Old format - just derive key (backward compatibility)
701            let key = Self::from_passphrase(passphrase, salt)?;
702            // Clear failed attempts on successful load
703            clear_failed_attempts(&key_file_str);
704            return Ok(key);
705        }
706
707        if encrypted_data.len() < SALT_SIZE + 1 + 32 {
708            return Err("Invalid key file format (unexpected size)".to_string());
709        }
710
711        let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
712
713        // Derive key from passphrase
714        let key = Self::from_passphrase(passphrase, salt)?;
715
716        // Verify passphrase using constant-time comparison
717        use sha2::{Digest, Sha256};
718        let mut hasher = Sha256::new();
719        hasher.update(b"lit-passphrase-verification-v1");
720        hasher.update(&key.key_bytes);
721        let verification_hash = hasher.finalize();
722
723        // Constant-time comparison to prevent timing attacks
724        use subtle::ConstantTimeEq;
725        if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
726            // SECURITY: Record failed attempt — test bypass only in test builds (FINDING-001)
727            #[cfg(not(test))]
728            record_failed_attempt(&key_file_str);
729            #[cfg(test)]
730            if !passphrase.starts_with("test-") {
731                record_failed_attempt(&key_file_str);
732            }
733            // Add delay to prevent timing-based passphrase enumeration
734            std::thread::sleep(std::time::Duration::from_millis(100));
735            return Err("Invalid passphrase".to_string());
736        }
737
738        // Clear failed attempts on successful authentication
739        clear_failed_attempts(&key_file_str);
740        Ok(key)
741    }
742
743    /// Save key to encrypted key file
744    /// SECURITY: Uses atomic write (temp file + rename) to prevent corruption
745    /// on crash or power loss. Includes verification hash for passphrase validation.
746    pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
747        let expanded = shellexpand::tilde(key_file_str);
748        let key_file = Path::new(expanded.as_ref());
749
750        // Generate verification hash using current key
751        use sha2::{Digest, Sha256};
752        let mut hasher = Sha256::new();
753        hasher.update(b"lit-passphrase-verification-v1");
754        hasher.update(self.key_bytes);
755        let verification_hash = hasher.finalize();
756
757        // Create key file directory if needed
758        if let Some(parent) = key_file.parent() {
759            fs::create_dir_all(parent)
760                .map_err(|e| format!("Failed to create key directory: {}", e))?;
761            // Key files are named after the repositories they belong to, so the
762            // listing itself is worth keeping to the owner. Best effort: a
763            // directory the user chose and does not own is their business.
764            let _ = restrict_dir_to_owner(parent);
765        }
766
767        // Store: salt + version + verification_hash
768        let mut data = Vec::new();
769        data.extend_from_slice(&self.salt);
770        data.push(ENCRYPTION_VERSION);
771        data.extend_from_slice(&verification_hash);
772
773        // Atomic write: write to temp file then rename to prevent corruption
774        let temp_file = key_file.with_extension("tmp");
775        fs::write(&temp_file, &data)
776            .map_err(|e| format!("Failed to write temp key file: {}", e))?;
777
778        // Restrict before the file takes its real name, so it is never briefly
779        // readable under the path an attacker would watch.
780        //
781        // No key material is stored here — the key is derived from the
782        // passphrase and this salt — but the verification hash lets anyone
783        // holding the file test passphrase guesses offline, without needing the
784        // repository at all. That is worth keeping to the owner.
785        restrict_to_owner(&temp_file)?;
786
787        // Windows refuses to rename onto a read-only file, and the file being
788        // replaced is one this function marked read-only last time. Clearing
789        // the attribute first is what lets `rotate-key` save a second time; a
790        // test covers it, because the failure only appears on the second save.
791        allow_replacement(key_file)?;
792
793        fs::rename(&temp_file, key_file)
794            .map_err(|e| format!("Failed to rename key file: {}", e))?;
795
796        Ok(())
797    }
798
799    /// Get raw key bytes (used internally)
800    fn as_bytes(&self) -> &[u8; KEY_SIZE] {
801        &self.key_bytes
802    }
803}
804
805/// Maximum encryptions per key (NIST SP 800-38D recommendation)
806/// Never exceed 2^32 encryptions with same key to prevent nonce reuse
807const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
808
809/// Encryption engine using AES-256-GCM
810/// SECURITY: Uses atomic counter to guarantee nonce uniqueness
811pub struct EncryptionEngine {
812    cipher: Aes256Gcm,
813    /// Atomic counter for nonce generation (ensures uniqueness)
814    nonce_counter: AtomicU64,
815}
816
817impl EncryptionEngine {
818    /// Create new encryption engine with key
819    pub fn new(key: &EncryptionKey) -> Result<Self, String> {
820        // Every AES-GCM operation in the crate goes through an engine, so this
821        // is the one place that can promise the self-tests ran first no matter
822        // which binary is driving. Runs once per process.
823        crate::crypto::fips::ensure_self_tests()?;
824
825        let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
826            .map_err(|e| format!("Failed to create cipher: {}", e))?;
827
828        Ok(EncryptionEngine {
829            cipher,
830            nonce_counter: AtomicU64::new(0),
831        })
832    }
833
834    /// Encrypt data with authenticated encryption (AES-256-GCM)
835    ///
836    /// Format: [version: 1 byte][nonce: 12 bytes][ciphertext + auth tag]
837    /// SECURITY: Uses counter-based nonce to guarantee uniqueness
838    #[allow(deprecated)]
839    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
840        // Invocation limit for a random nonce (NIST SP 800-38D §8.3).
841        //
842        // The counter is per engine, so this bounds one process rather than the
843        // lifetime of the key; a durable count would need state that survives
844        // the command. It is a backstop, not the guarantee — `rotate-key`
845        // remains the real control.
846        let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
847        if count >= MAX_ENCRYPTIONS_PER_KEY {
848            return Err(format!(
849                "Encryption limit exceeded ({} operations). Key rotation required for security.",
850                MAX_ENCRYPTIONS_PER_KEY
851            ));
852        }
853
854        // Nonce: 96 random bits, the RBG-based construction of NIST SP 800-38D
855        // §8.2.2, which is why the invocation limit above is 2^32.
856        //
857        // This was previously a counter in the top 8 bytes with 4 random bytes
858        // after it, described as guaranteeing uniqueness. It did not: the
859        // counter lives in the engine and restarts at zero for every engine —
860        // every process, and every store or index opened within one — so the
861        // first encryption after each start always reused counter 0 and only
862        // those 4 random bytes stood between two nonces. Colliding 32 bits is
863        // a birthday problem over roughly 65,000 encryptions, and a repeated
864        // nonce under one AES-GCM key does not merely leak the XOR of the two
865        // plaintexts, it exposes the GHASH key and with it forgery.
866        //
867        // 96 random bits put the same collision out of reach, and the nonce is
868        // stored alongside the ciphertext, so data written under the old scheme
869        // still decrypts.
870        use aes_gcm::aead::rand_core::RngCore;
871        let mut nonce_bytes = [0u8; NONCE_SIZE];
872        OsRng.fill_bytes(&mut nonce_bytes);
873        let nonce = Nonce::from_slice(&nonce_bytes);
874
875        // Encrypt with authenticated encryption
876        let ciphertext = self
877            .cipher
878            .encrypt(nonce, plaintext)
879            .map_err(|e| format!("Encryption failed: {}", e))?;
880
881        // Build output: version + nonce + ciphertext
882        let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
883        output.push(ENCRYPTION_VERSION);
884        output.extend_from_slice(&nonce_bytes);
885        output.extend_from_slice(&ciphertext);
886
887        Ok(output)
888    }
889
890    /// Decrypt data with authentication verification
891    #[allow(deprecated)]
892    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
893        if encrypted.len() < 1 + NONCE_SIZE {
894            return Err("Invalid encrypted data: too short".to_string());
895        }
896
897        // Extract version
898        let version = encrypted[0];
899        if version != ENCRYPTION_VERSION {
900            return Err(format!("Unsupported encryption version: {}", version));
901        }
902
903        // Extract nonce
904        let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
905        let nonce = Nonce::from_slice(nonce_bytes);
906
907        // Extract ciphertext
908        let ciphertext = &encrypted[1 + NONCE_SIZE..];
909
910        // Decrypt and verify authentication tag
911        let plaintext = self
912            .cipher
913            .decrypt(nonce, ciphertext)
914            .map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
915
916        Ok(plaintext)
917    }
918}
919
920/// Passphrase cache operations
921impl CachedPassphrase {
922    /// Check if cached passphrase is still valid
923    fn is_valid(&self) -> bool {
924        SystemTime::now() < self.expires_at
925    }
926}
927
928/// Store passphrase in cache with timeout
929/// SECURITY: Passphrase stored in Zeroizing wrapper for automatic memory clearing
930pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
931    let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
932    let expires_at = SystemTime::now() + timeout;
933
934    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
935        cache.insert(
936            repo_path.to_string(),
937            CachedPassphrase {
938                passphrase: Zeroizing::new(passphrase),
939                expires_at,
940            },
941        );
942    }
943}
944
945/// Retrieve cached passphrase if valid
946/// SECURITY: Returns clone of Zeroizing-wrapped passphrase
947pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
948    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
949        if let Some(entry) = cache.get(repo_path) {
950            if entry.is_valid() {
951                return Some(entry.passphrase.clone());
952            } else {
953                // Remove expired entry (passphrase auto-zeroized on drop)
954                cache.remove(repo_path);
955            }
956        }
957    }
958    None
959}
960
961/// Clear all cached passphrases
962pub fn clear_passphrase_cache() {
963    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
964        cache.clear();
965    }
966}
967
968/// Clear cached passphrase for specific repository
969pub fn clear_cached_passphrase(repo_path: &str) {
970    if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
971        cache.remove(repo_path);
972    }
973}
974
975/// Get passphrase from non-interactive sources
976///
977/// Priority: LIT_PASSPHRASE env var > LIT_PASSPHRASE_FILE env var > cache
978/// Returns None if no non-interactive source is available.
979/// SECURITY: Returns Zeroizing<String> to ensure passphrase is cleared from memory.
980fn get_passphrase_non_interactive(
981    repo_path: &str,
982    config: &EncryptionConfig,
983) -> Option<Zeroizing<String>> {
984    // 1. Check LIT_PASSPHRASE env var
985    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
986        if !pass.is_empty() {
987            return Some(Zeroizing::new(pass));
988        }
989    }
990
991    // 2. Check LIT_PASSPHRASE_FILE env var
992    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
993        if let Ok(pass) = std::fs::read_to_string(&path) {
994            let pass = pass
995                .trim_end_matches('\n')
996                .trim_end_matches('\r')
997                .to_string();
998            if !pass.is_empty() {
999                return Some(Zeroizing::new(pass));
1000            }
1001        }
1002    }
1003
1004    // 3. Check cache
1005    if config.cache_timeout_secs > 0 {
1006        if let Some(cached) = get_cached_passphrase(repo_path) {
1007            return Some(cached);
1008        }
1009    }
1010
1011    // 4. Ask the agent, if one is running. This is the only source that spans
1012    //    commands — the cache above cannot, since each command is a new
1013    //    process. Deliberately last: an explicitly supplied passphrase should
1014    //    win over a stored one, so that overriding it does not require stopping
1015    //    the agent first.
1016    if let Some(from_agent) = crate::crypto::agent::get(repo_path) {
1017        return Some(from_agent);
1018    }
1019
1020    None
1021}
1022
1023/// Prompt user for passphrase securely via CLI
1024///
1025/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > cache > interactive prompt.
1026/// In non-interactive mode (default for agents), returns error if no passphrase
1027/// is available from env/file/cache.
1028pub fn prompt_for_passphrase(
1029    repo_path: &str,
1030    config: &EncryptionConfig,
1031    prompt_text: &str,
1032) -> Result<Zeroizing<String>, String> {
1033    // Try non-interactive sources first
1034    if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
1035        return Ok(pass);
1036    }
1037
1038    // Agent safety: never block on an interactive prompt when there is no TTY
1039    // (the default for agents, pipes, and CI). Fail fast with remediation.
1040    if !std::io::stdin().is_terminal() {
1041        return Err(
1042            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
1043             LIT_PASSPHRASE_FILE"
1044                .to_string(),
1045        );
1046    }
1047
1048    // Fall back to interactive prompt
1049    rpassword::prompt_password(prompt_text)
1050        .map(Zeroizing::new)
1051        .map_err(|e| format!("Failed to read passphrase: {}", e))
1052}
1053
1054/// Minimum passphrase length (NIST SP 800-63B recommendation for high security)
1055const MIN_PASSPHRASE_LENGTH: usize = 16;
1056
1057/// Validate passphrase strength
1058///
1059/// Requirements:
1060/// - Minimum 16 characters (NIST SP 800-63B)
1061/// - At least 3 of: uppercase, lowercase, digits, special characters
1062fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
1063    // SECURITY: Test bypass only available in test builds (FINDING-001)
1064    #[cfg(test)]
1065    if passphrase.starts_with("test-") {
1066        return Ok(());
1067    }
1068
1069    if passphrase.len() < MIN_PASSPHRASE_LENGTH {
1070        return Err(format!(
1071            "Passphrase must be at least {} characters (recommended: 20+)",
1072            MIN_PASSPHRASE_LENGTH
1073        ));
1074    }
1075
1076    // Check complexity
1077    let has_upper = passphrase.chars().any(|c| c.is_uppercase());
1078    let has_lower = passphrase.chars().any(|c| c.is_lowercase());
1079    let has_digit = passphrase.chars().any(|c| c.is_numeric());
1080    let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
1081
1082    let complexity_count = [has_upper, has_lower, has_digit, has_special]
1083        .iter()
1084        .filter(|&&x| x)
1085        .count();
1086
1087    if complexity_count < 3 {
1088        return Err(
1089            "Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
1090                .to_string(),
1091        );
1092    }
1093
1094    Ok(())
1095}
1096
1097/// Prompt for passphrase confirmation (for new passphrases)
1098///
1099/// Priority: LIT_PASSPHRASE env > LIT_PASSPHRASE_FILE > interactive prompt (with confirmation).
1100pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
1101    // Check LIT_PASSPHRASE env var
1102    if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
1103        if !pass.is_empty() {
1104            validate_passphrase_strength(&pass)?;
1105            return Ok(Zeroizing::new(pass));
1106        }
1107    }
1108
1109    // Check LIT_PASSPHRASE_FILE env var
1110    if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
1111        if let Ok(pass) = std::fs::read_to_string(&path) {
1112            let pass = pass
1113                .trim_end_matches('\n')
1114                .trim_end_matches('\r')
1115                .to_string();
1116            if !pass.is_empty() {
1117                validate_passphrase_strength(&pass)?;
1118                return Ok(Zeroizing::new(pass));
1119            }
1120        }
1121    }
1122
1123    // Agent safety: never block on an interactive prompt when there is no TTY.
1124    if !std::io::stdin().is_terminal() {
1125        return Err(
1126            "no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
1127             LIT_PASSPHRASE_FILE"
1128                .to_string(),
1129        );
1130    }
1131
1132    // Interactive prompt with confirmation
1133    let pass1 = rpassword::prompt_password(prompt_text)
1134        .map_err(|e| format!("Failed to read passphrase: {}", e))?;
1135
1136    let pass2 = rpassword::prompt_password("Confirm passphrase: ")
1137        .map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
1138
1139    if pass1 != pass2 {
1140        return Err("Passphrases do not match".to_string());
1141    }
1142
1143    validate_passphrase_strength(&pass1)?;
1144
1145    Ok(Zeroizing::new(pass1))
1146}
1147
1148/// Encryption manager for repository
1149pub struct EncryptionManager {
1150    config: EncryptionConfig,
1151    engine: Option<EncryptionEngine>,
1152    repo_path: Option<String>,
1153    /// Why `new_auto` could not unlock, if it could not.
1154    ///
1155    /// `new_auto` cannot return an error — every store is built through it —
1156    /// so the reason used to go to stderr and the user got "Encryption not
1157    /// initialized" from the first read instead, which names the symptom and
1158    /// not the cause. Kept here so the read reports what actually went wrong.
1159    unlock_error: Option<String>,
1160}
1161
1162impl EncryptionManager {
1163    /// Create new encryption manager
1164    pub fn new(config: EncryptionConfig) -> Self {
1165        EncryptionManager {
1166            config,
1167            engine: None,
1168            repo_path: None,
1169            unlock_error: None,
1170        }
1171    }
1172
1173    /// Build a manager around a key that is already in hand.
1174    ///
1175    /// `rotate-key` is why this exists. It has to encrypt with the *new* key
1176    /// while the key file on disk still describes the old one, and going
1177    /// through `initialize` there reads that file: either it rejects the new
1178    /// passphrase outright, or it derives the new passphrase against the old
1179    /// salt and writes data that the key file it then saves cannot open.
1180    pub fn from_key(config: EncryptionConfig, key: &EncryptionKey) -> Result<Self, String> {
1181        Ok(EncryptionManager {
1182            config,
1183            engine: Some(EncryptionEngine::new(key)?),
1184            repo_path: None,
1185            unlock_error: None,
1186        })
1187    }
1188
1189    /// Build a manager, initializing it from a non-interactive passphrase
1190    /// source when encryption is enabled and one is available.
1191    ///
1192    /// Every command builds its object store through `ObjectStore::new`, which
1193    /// returns `Self` rather than a `Result` and must not prompt — Lit is
1194    /// zero-prompt by design. So the passphrase comes from `LIT_PASSPHRASE`,
1195    /// `LIT_PASSPHRASE_FILE` or the cache, and nothing else.
1196    ///
1197    /// With encryption enabled and no source available the manager stays
1198    /// uninitialized on purpose: the first encrypt or decrypt then reports
1199    /// that plainly, which is a better failure than a constructor that cannot
1200    /// explain itself.
1201    pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
1202        let mut manager = EncryptionManager::new(config);
1203        if !manager.config.enabled {
1204            return manager;
1205        }
1206
1207        let repo = repo_path.to_string_lossy().to_string();
1208        let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
1209            return manager;
1210        };
1211
1212        manager.repo_path = Some(repo.clone());
1213        if let Err(e) = manager.initialize(&passphrase) {
1214            eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
1215            manager.unlock_error = Some(e);
1216            return manager;
1217        }
1218
1219        if manager.config.cache_timeout_secs > 0 {
1220            let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
1221            cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
1222        }
1223
1224        manager
1225    }
1226
1227    /// Whether `data` carries our encryption header.
1228    ///
1229    /// Lets a reader tell ciphertext from content written before encryption
1230    /// was switched on, so a repository part-way through migration stays
1231    /// readable. Nothing we write in the clear begins with this byte: refs hold
1232    /// hex or `ref: `, the index holds JSON.
1233    pub fn is_encrypted_payload(data: &[u8]) -> bool {
1234        data.first() == Some(&ENCRYPTION_VERSION)
1235    }
1236
1237    /// Initialize encryption with passphrase
1238    pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
1239        if !self.config.enabled {
1240            return Ok(());
1241        }
1242
1243        let expanded = shellexpand::tilde(&self.config.key_file);
1244        let key_file = Path::new(expanded.as_ref());
1245
1246        // A command opens several stores — the object store, the index, and the
1247        // pack reader behind them — and each one lands here. Deriving the key
1248        // every time means paying PBKDF2's 600,000 iterations several times
1249        // over for a single `lit status`. Reuse a key already derived in this
1250        // process for the same file and passphrase.
1251        //
1252        // The cache is memory-only and dies with the process, so it widens no
1253        // window that holding the key for the length of one command already
1254        // opens. Only successful derivations are stored, so a wrong passphrase
1255        // still goes the long way round and still meets the rate limiter.
1256        let cache_id = derived_key_id(expanded.as_ref(), passphrase);
1257        if let Some(key) = cached_derived_key(&cache_id) {
1258            self.engine = Some(EncryptionEngine::new(&key)?);
1259            return Ok(());
1260        }
1261
1262        // Load or create encryption key
1263        let key = if key_file.exists() {
1264            EncryptionKey::load(key_file, passphrase)
1265                .map_err(|e| explain_key_failure(e, key_file))?
1266        } else {
1267            self.refuse_to_replace_a_lost_key(key_file)?;
1268            let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
1269            key.save(&self.config.key_file, passphrase)?;
1270            key
1271        };
1272
1273        let key = std::sync::Arc::new(key);
1274        remember_derived_key(cache_id, std::sync::Arc::clone(&key));
1275
1276        // Create encryption engine
1277        self.engine = Some(EncryptionEngine::new(&key)?);
1278
1279        Ok(())
1280    }
1281
1282    /// Refuse to mint a fresh key for a repository that is already encrypted.
1283    ///
1284    /// A missing key file is normal exactly once — the first time encryption is
1285    /// switched on. Afterwards it means the key was deleted, or the repository
1286    /// was moved and its config could not record the path it resolved to. In
1287    /// both cases the old code quietly created a *different* key, and the
1288    /// repository then failed to decrypt with an error about headers that named
1289    /// nothing the user had done. Say what actually happened instead.
1290    ///
1291    /// Content encrypted earlier is what distinguishes the two cases, and it
1292    /// has to be judged without a key: `refs.enc` only exists in an encrypted
1293    /// repository, and an encrypted index starts with our version byte. A
1294    /// repository still holding plaintext — the state `migrate-encryption`
1295    /// exists to fix — matches neither, and is still allowed to create a key.
1296    fn refuse_to_replace_a_lost_key(&self, key_file: &Path) -> Result<(), String> {
1297        let Some(repo) = self.repo_path.as_deref() else {
1298            return Ok(());
1299        };
1300        let lit = Path::new(repo).join(".lit");
1301
1302        let index_is_encrypted = fs::read(lit.join("index"))
1303            .map(|data| Self::is_encrypted_payload(&data))
1304            .unwrap_or(false);
1305
1306        if !lit.join("refs.enc").exists() && !index_is_encrypted {
1307            return Ok(());
1308        }
1309
1310        Err(format!(
1311            "This repository is encrypted, but its key file is missing: {}. \
1312             A new key would not open the existing data. Restore the key file \
1313             from backup, or point key_file in .lit/encryption.toml at wherever \
1314             it now lives.",
1315            key_file.display()
1316        ))
1317    }
1318
1319    /// Initialize encryption with passphrase caching support
1320    pub fn initialize_with_cache(
1321        &mut self,
1322        repo_path: &str,
1323        passphrase: Option<&str>,
1324    ) -> Result<(), String> {
1325        if !self.config.enabled {
1326            return Ok(());
1327        }
1328
1329        self.repo_path = Some(repo_path.to_string());
1330
1331        // Try to get cached passphrase first
1332        let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
1333            Zeroizing::new(pass.to_string())
1334        } else if let Some(cached) = get_cached_passphrase(repo_path) {
1335            cached
1336        } else {
1337            return Err("No passphrase provided and no valid cached passphrase found".to_string());
1338        };
1339
1340        // Initialize encryption
1341        self.initialize(&actual_passphrase)?;
1342
1343        // Cache the passphrase if caching is enabled
1344        if self.config.cache_timeout_secs > 0 {
1345            let timeout = Duration::from_secs(self.config.cache_timeout_secs);
1346            cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
1347        }
1348
1349        Ok(())
1350    }
1351
1352    /// Encrypt data if encryption is enabled
1353    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
1354        if !self.config.enabled {
1355            return Ok(plaintext.to_vec());
1356        }
1357
1358        match &self.engine {
1359            Some(engine) => engine.encrypt(plaintext),
1360            None => Err(self.locked_reason()),
1361        }
1362    }
1363
1364    /// What to say when there is no engine to work with.
1365    fn locked_reason(&self) -> String {
1366        self.unlock_error.clone().unwrap_or_else(|| {
1367            "Encryption not initialized. Call initialize() with passphrase first.".to_string()
1368        })
1369    }
1370
1371    /// Decrypt data if encryption is enabled
1372    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
1373        if !self.config.enabled {
1374            return Ok(encrypted.to_vec());
1375        }
1376
1377        match &self.engine {
1378            Some(engine) => {
1379                // Data written before encryption was switched on carries no
1380                // header of ours, so it fails here with a version number taken
1381                // from whatever byte happened to be first — 123 for the `{` of
1382                // the plaintext index, which explains nothing. Encryption
1383                // cannot be turned on for a repository that already has
1384                // content, and this is where a user finds that out.
1385                if encrypted
1386                    .first()
1387                    .is_some_and(|version| *version != ENCRYPTION_VERSION)
1388                {
1389                    return Err(
1390                        "This data has no Lit encryption header. Encryption cannot be \
1391                         enabled for a repository that already contains unencrypted \
1392                         commits — start a new encrypted repository and import into it."
1393                            .to_string(),
1394                    );
1395                }
1396                engine.decrypt(encrypted)
1397            }
1398            None => Err(self.locked_reason()),
1399        }
1400    }
1401
1402    /// Check if encryption is enabled
1403    pub fn is_enabled(&self) -> bool {
1404        self.config.enabled
1405    }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410    use super::*;
1411
1412    /// Serializes tests that mutate the process-global passphrase cache.
1413    ///
1414    /// Several tests call [`clear_passphrase_cache`], which wipes every entry;
1415    /// running them in parallel lets one test clear another's freshly-cached
1416    /// entry, producing spurious failures. Holding this lock makes those tests
1417    /// mutually exclusive. Poisoning is recovered from since a panic in one
1418    /// test must not cascade into the others.
1419    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1420
1421    fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
1422        CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
1423    }
1424
1425    /// A key-file path belonging to a single test.
1426    ///
1427    /// Tests that exercise `EncryptionKey::save`/`load` write a real file, and
1428    /// the rate-limiter keys its failed-attempt tracker off that path. Pointing
1429    /// them at `~/.lit/encryption.key` therefore made them collide with one
1430    /// another — and, since they delete the file to start clean, destroyed the
1431    /// operator's real key on any run that included them. A per-test path in
1432    /// the temp directory isolates the file and the tracker together.
1433    fn test_key_path(label: &str) -> std::path::PathBuf {
1434        static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1435        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1436        let path = std::env::temp_dir().join(format!(
1437            "lit_enc_test_{}_{}_{}.key",
1438            std::process::id(),
1439            label,
1440            n
1441        ));
1442        let _ = fs::remove_file(&path);
1443        path
1444    }
1445
1446    #[test]
1447    fn test_key_derivation() {
1448        let passphrase = "test-passphrase-12345";
1449        let salt = EncryptionKey::generate_salt();
1450
1451        let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1452        let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1453
1454        // Same passphrase and salt should produce same key
1455        assert_eq!(key1.as_bytes(), key2.as_bytes());
1456    }
1457
1458    #[test]
1459    fn test_encryption_decryption() {
1460        let passphrase = "test-secure-passphrase";
1461        let salt = EncryptionKey::generate_salt();
1462        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1463
1464        let engine = EncryptionEngine::new(&key).unwrap();
1465
1466        let plaintext = b"Hello, this is secret data!";
1467
1468        // Encrypt
1469        let encrypted = engine.encrypt(plaintext).unwrap();
1470
1471        // Verify encrypted data is different
1472        assert_ne!(encrypted.as_slice(), plaintext);
1473
1474        // Decrypt
1475        let decrypted = engine.decrypt(&encrypted).unwrap();
1476
1477        // Verify original data restored
1478        assert_eq!(decrypted.as_slice(), plaintext);
1479    }
1480
1481    #[test]
1482    fn test_encryption_nonce_randomness() {
1483        let passphrase = "test-passphrase";
1484        let salt = EncryptionKey::generate_salt();
1485        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1486
1487        let engine = EncryptionEngine::new(&key).unwrap();
1488
1489        let plaintext = b"Same data";
1490
1491        // Encrypt same data twice
1492        let encrypted1 = engine.encrypt(plaintext).unwrap();
1493        let encrypted2 = engine.encrypt(plaintext).unwrap();
1494
1495        // Should produce different ciphertexts (different nonces)
1496        assert_ne!(encrypted1, encrypted2);
1497
1498        // But both should decrypt to same plaintext
1499        assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
1500        assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
1501    }
1502
1503    #[test]
1504    fn test_tampering_detection() {
1505        let passphrase = "test-passphrase";
1506        let salt = EncryptionKey::generate_salt();
1507        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1508
1509        let engine = EncryptionEngine::new(&key).unwrap();
1510
1511        let plaintext = b"Secret data";
1512        let mut encrypted = engine.encrypt(plaintext).unwrap();
1513
1514        // Tamper with ciphertext
1515        let len = encrypted.len();
1516        encrypted[len - 1] ^= 0x01;
1517
1518        // Decryption should fail due to authentication tag mismatch
1519        assert!(engine.decrypt(&encrypted).is_err());
1520    }
1521
1522    #[test]
1523    fn test_encryption_manager_disabled() {
1524        let config = EncryptionConfig {
1525            enabled: false,
1526            ..Default::default()
1527        };
1528
1529        let manager = EncryptionManager::new(config);
1530
1531        let data = b"Some data";
1532
1533        // When disabled, should return data as-is
1534        assert_eq!(manager.encrypt(data).unwrap(), data);
1535        assert_eq!(manager.decrypt(data).unwrap(), data);
1536    }
1537
1538    #[test]
1539    fn test_passphrase_caching() {
1540        let _guard = cache_test_guard();
1541        let repo_path = "/tmp/test-repo";
1542        let passphrase = "cache-test-passphrase".to_string();
1543
1544        // Clear cache first
1545        clear_passphrase_cache();
1546
1547        // Should return None when not cached
1548        assert!(get_cached_passphrase(repo_path).is_none());
1549
1550        // Cache passphrase with 5 second timeout
1551        cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1552
1553        // Should retrieve cached passphrase
1554        assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1555
1556        // Clear specific entry
1557        clear_cached_passphrase(repo_path);
1558        assert!(get_cached_passphrase(repo_path).is_none());
1559    }
1560
1561    #[test]
1562    fn test_passphrase_cache_expiration() {
1563        let _guard = cache_test_guard();
1564        let repo_path = "/tmp/test-repo-expire";
1565        let passphrase = "expire-test".to_string();
1566
1567        clear_passphrase_cache();
1568
1569        // Cache with a short timeout, then wait well past it and assert the
1570        // entry was evicted. This test deliberately avoids asserting immediate
1571        // availability — that behavior is covered by `test_passphrase_caching`,
1572        // and a tight "available right now" check would race the timeout under
1573        // heavy parallel CPU load. Asserting only expiration is robust: more
1574        // load can only make the entry *more* expired, never less.
1575        cache_passphrase(
1576            repo_path,
1577            passphrase.clone(),
1578            Some(Duration::from_millis(200)),
1579        );
1580
1581        std::thread::sleep(Duration::from_millis(600));
1582
1583        // Should be expired and removed
1584        assert!(get_cached_passphrase(repo_path).is_none());
1585    }
1586
1587    #[test]
1588    fn test_passphrase_cache_multiple_repos() {
1589        let _guard = cache_test_guard();
1590        let repo1 = "/tmp/multi-cache-repo1";
1591        let repo2 = "/tmp/multi-cache-repo2";
1592        let pass1 = "password1".to_string();
1593        let pass2 = "password2".to_string();
1594
1595        clear_passphrase_cache();
1596
1597        // Cache different passphrases for different repos
1598        cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1599        cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1600
1601        // Should retrieve correct passphrase for each repo
1602        assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
1603        assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
1604    }
1605
1606    #[test]
1607    fn test_encryption_manager_with_cache() {
1608        use std::env;
1609
1610        let _guard = cache_test_guard();
1611
1612        let key_file = test_key_path("manager_cache");
1613
1614        let temp_dir = env::temp_dir();
1615        let repo_path = temp_dir.join("test-cache-manager");
1616        let repo_str = repo_path.to_str().unwrap();
1617
1618        clear_passphrase_cache();
1619
1620        let config = EncryptionConfig {
1621            enabled: true,
1622            key_file: key_file.to_string_lossy().into_owned(),
1623            cache_timeout_secs: 300, // 5 minutes
1624            ..Default::default()
1625        };
1626
1627        let mut manager = EncryptionManager::new(config);
1628        let passphrase = "test-cache-manager-pass";
1629
1630        // Initialize with cache
1631        manager
1632            .initialize_with_cache(repo_str, Some(passphrase))
1633            .unwrap();
1634
1635        // Passphrase should be cached
1636        assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1637
1638        // Should be able to initialize again without providing passphrase
1639        let mut manager2 = EncryptionManager::new(manager.config.clone());
1640        manager2.initialize_with_cache(repo_str, None).unwrap();
1641
1642        // Clear cache for cleanup
1643        clear_passphrase_cache();
1644        let _ = fs::remove_file(&key_file);
1645    }
1646
1647    /// The throttle has to outlive the process that recorded the attempts.
1648    ///
1649    /// Every `lit` command is a new process. While the counter lived in a
1650    /// `static`, each one started at zero: the exponential backoff never grew
1651    /// past its first step and the five-attempt lockout could not be reached at
1652    /// all by a script that reran the binary, which is the case it exists for.
1653    /// Reading the state back from disk is what a second process does, so that
1654    /// is what this asserts.
1655    #[test]
1656    fn test_throttle_state_outlives_the_process() {
1657        let dir = tempfile::tempdir().unwrap();
1658        let key_path = dir.path().join("outlives.key");
1659        let key = key_path.to_string_lossy().into_owned();
1660
1661        for _ in 0..5 {
1662            record_failed_attempt(&key);
1663        }
1664
1665        // What a freshly started process would see.
1666        let seen = load_throttle(&key);
1667        assert_eq!(seen.count, 5, "the count should have survived on disk");
1668        assert!(
1669            seen.lockout_until.is_some(),
1670            "five failures should have produced a lockout a new process can see"
1671        );
1672
1673        // And it should actually refuse, rather than merely recording a number.
1674        assert!(
1675            check_rate_limit(&key).is_err(),
1676            "a locked-out key should be refused"
1677        );
1678
1679        // A correct passphrase clears it, so an ordinary typo is not sticky.
1680        clear_failed_attempts(&key);
1681        assert_eq!(load_throttle(&key).count, 0);
1682        assert!(check_rate_limit(&key).is_ok());
1683    }
1684
1685    /// Corrupt or unreadable state must not lock the owner out of their own
1686    /// repository — the throttle slows guessing, it is not an access control.
1687    #[test]
1688    fn test_unreadable_throttle_state_is_treated_as_a_clean_slate() {
1689        let dir = tempfile::tempdir().unwrap();
1690        let key_path = dir.path().join("corrupt.key");
1691        let key = key_path.to_string_lossy().into_owned();
1692
1693        fs::write(throttle_state_path(&key), b"this is not json").unwrap();
1694
1695        assert_eq!(load_throttle(&key).count, 0);
1696        assert!(check_rate_limit(&key).is_ok());
1697    }
1698
1699    /// Exercises the brute-force throttle on `EncryptionKey::load`.
1700    ///
1701    /// Ignored for runtime, not correctness: each attempt that gets as far as
1702    /// verification runs PBKDF2 at 600k iterations, which costs seconds in an
1703    /// unoptimized build. Run it with `cargo test -- --ignored`.
1704    #[test]
1705    #[ignore]
1706    fn test_rate_limiting() {
1707        let key_file = test_key_path("rate_limiting");
1708        let key_file_str = key_file.to_string_lossy().into_owned();
1709
1710        // A passphrase that does NOT start with "test-", so the test-only
1711        // bypass in `load` leaves the rate-limit check in play.
1712        let passphrase = "correct-passphrase-1234567890";
1713        let salt = EncryptionKey::generate_salt();
1714        let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
1715        key.save(&key_file_str, passphrase).unwrap();
1716
1717        // A wrong passphrase is rejected on its merits, and counted.
1718        assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1719
1720        // The next attempt falls inside the backoff window, so the throttle
1721        // turns it away before any verification happens. The throttle refuses
1722        // rather than sleeping, so the caller is told how long to wait instead
1723        // of having a thread parked on its behalf.
1724        // `unwrap_err` is avoided throughout: it would require `EncryptionKey`
1725        // to be `Debug`, and that type holds live key material.
1726        let start = std::time::Instant::now();
1727        let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
1728            .err()
1729            .expect("an attempt inside the backoff window must be refused");
1730        assert!(
1731            throttled.contains("wait"),
1732            "expected a rate-limit refusal, got: {}",
1733            throttled
1734        );
1735        assert!(
1736            start.elapsed() < Duration::from_secs(1),
1737            "the throttle should refuse immediately rather than block the caller"
1738        );
1739
1740        // Once the 2^1-second window passes, attempts are evaluated again — the
1741        // failure that comes back is about the passphrase, not the throttle.
1742        std::thread::sleep(Duration::from_millis(2_100));
1743        let correct = EncryptionKey::load(&key_file, passphrase);
1744        assert!(
1745            correct.is_ok(),
1746            "the correct passphrase should be accepted once the window passes: {:?}",
1747            correct.as_ref().err()
1748        );
1749
1750        // Success clears the counter, so the next wrong attempt is judged on
1751        // its merits rather than being thrown out by the throttle.
1752        let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
1753            .err()
1754            .expect("a wrong passphrase must still fail");
1755        assert!(
1756            !after_reset.contains("wait"),
1757            "a successful load should reset the counter, got: {}",
1758            after_reset
1759        );
1760
1761        let _ = fs::remove_file(&key_file);
1762    }
1763
1764    /// Nonces must not repeat across freshly created engines.
1765    ///
1766    /// The old construction put an engine-local counter in the top 8 bytes of
1767    /// the nonce, so every new engine — every process, every store opened —
1768    /// started again at zero and the first encryption always carried the same
1769    /// 8 leading bytes. Only 4 random bytes separated two such nonces, and a
1770    /// repeated nonce under one AES-GCM key is catastrophic. Simulate a run of
1771    /// separate processes and require the nonces to be distinct.
1772    #[test]
1773    fn test_nonces_do_not_repeat_across_engines() {
1774        let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
1775
1776        let mut nonces = std::collections::HashSet::new();
1777        let mut leading_zero_runs = 0;
1778
1779        for _ in 0..64 {
1780            // A fresh engine each time, as a new process would build.
1781            let engine = EncryptionEngine::new(&key).unwrap();
1782            let blob = engine.encrypt(b"same plaintext every time").unwrap();
1783            let nonce = blob[1..1 + NONCE_SIZE].to_vec();
1784
1785            if nonce[..8] == [0u8; 8] {
1786                leading_zero_runs += 1;
1787            }
1788            assert!(
1789                nonces.insert(nonce),
1790                "a nonce repeated across engines, which breaks AES-GCM"
1791            );
1792        }
1793
1794        // Under the old scheme every one of these would have started 0x00 * 8.
1795        assert!(
1796            leading_zero_runs <= 1,
1797            "{} of 64 nonces began with eight zero bytes, which means the \
1798             counter is resetting rather than the nonce being random",
1799            leading_zero_runs
1800        );
1801    }
1802}
1803
1804#[cfg(test)]
1805mod key_file_permission_tests {
1806    use super::*;
1807
1808    /// Saving over an existing key file must keep working.
1809    ///
1810    /// The key file is restricted to its owner before being renamed into
1811    /// place. On Windows that restriction is the read-only attribute, and
1812    /// `fs::rename` onto a read-only destination is exactly what `rotate-key`
1813    /// does — so if it failed, rotation would break on the second save.
1814    #[test]
1815    fn test_key_file_can_be_saved_over() {
1816        let path = std::env::temp_dir().join(format!("lit_keyperm_{}.key", std::process::id()));
1817        let _ = fs::remove_file(&path);
1818        let path_str = path.to_string_lossy().to_string();
1819
1820        let first =
1821            EncryptionKey::from_passphrase("FirstPassphrase!123", &[1u8; SALT_SIZE]).unwrap();
1822        first
1823            .save(&path_str, "FirstPassphrase!123")
1824            .expect("first save should succeed");
1825
1826        let second =
1827            EncryptionKey::from_passphrase("SecondPassphrase!234", &[2u8; SALT_SIZE]).unwrap();
1828        second
1829            .save(&path_str, "SecondPassphrase!234")
1830            .expect("saving over an existing key file should succeed, as rotate-key does");
1831
1832        // The second key's salt should be what is on disk now.
1833        let stored = fs::read(&path).unwrap();
1834        assert_eq!(
1835            &stored[..SALT_SIZE],
1836            &[2u8; SALT_SIZE],
1837            "the rewrite should have taken effect"
1838        );
1839
1840        let _ = fs::remove_file(&path);
1841    }
1842
1843    /// The restriction has to tighten a file that already exists, not only one
1844    /// this version created. Keys written before the restriction existed sit on
1845    /// disk with whatever the umask gave them, and only a call on the load path
1846    /// ever corrects them.
1847    #[test]
1848    fn test_restrict_to_owner_tightens_an_existing_permissive_file() {
1849        let dir = tempfile::tempdir().unwrap();
1850        let path = dir.path().join("preexisting.key");
1851        fs::write(&path, b"secret").unwrap();
1852
1853        #[cfg(unix)]
1854        {
1855            use std::os::unix::fs::PermissionsExt;
1856            let mut perms = fs::metadata(&path).unwrap().permissions();
1857            perms.set_mode(0o644);
1858            fs::set_permissions(&path, perms).unwrap();
1859
1860            restrict_to_owner(&path).unwrap();
1861
1862            let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1863            assert_eq!(mode, 0o600, "group and other should have lost all access");
1864        }
1865
1866        #[cfg(windows)]
1867        restrict_to_owner(&path).unwrap();
1868
1869        // Whatever the platform, the owner must still be able to read it back —
1870        // a restriction that locks out the process that applied it is a bug.
1871        assert_eq!(fs::read(&path).unwrap(), b"secret");
1872    }
1873}