1#![allow(unused_assignments)]
2use 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
28const KEY_SIZE: usize = 32;
30
31const DEFAULT_CACHE_TIMEOUT: Duration = Duration::from_secs(300);
33
34struct CachedPassphrase {
37 passphrase: Zeroizing<String>,
38 expires_at: SystemTime,
39}
40
41lazy_static! {
42 static ref PASSPHRASE_CACHE: Mutex<HashMap<String, CachedPassphrase>> = Mutex::new(HashMap::new());
44
45 static ref FAILED_ATTEMPTS: Mutex<HashMap<String, FailedAttemptTracker>> = Mutex::new(HashMap::new());
47
48 static ref DERIVED_KEYS: Mutex<HashMap<String, std::sync::Arc<EncryptionKey>>> = Mutex::new(HashMap::new());
51}
52
53struct FailedAttemptTracker {
55 count: u32,
56 last_attempt: SystemTime,
57 lockout_until: Option<SystemTime>,
58}
59
60const NONCE_SIZE: usize = 12;
62
63const PBKDF2_ITERATIONS: u32 = 600_000;
69
70const SALT_SIZE: usize = 16;
73
74const ENCRYPTION_VERSION: u8 = 1;
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EncryptionConfig {
80 pub enabled: bool,
82 #[serde(default)]
87 pub key_file: String,
88 pub fips_mode: bool,
90 #[serde(default = "default_cache_timeout")]
92 pub cache_timeout_secs: u64,
93}
94
95fn default_cache_timeout() -> u64 {
96 300 }
98
99impl Default for EncryptionConfig {
100 fn default() -> Self {
101 EncryptionConfig {
102 enabled: false,
103 key_file: String::new(),
106 fips_mode: true,
107 cache_timeout_secs: default_cache_timeout(),
108 }
109 }
110}
111
112pub 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
163const SHARED_KEY_FILE: &str = "~/.lit/encryption.key";
166
167fn 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 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 if config.enabled {
225 let _ = config.save(repo_path);
226 }
227 }
228
229 Ok(config)
230 }
231
232 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
244pub(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
267pub(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#[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 let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
318 wide.push(0);
319
320 unsafe {
321 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 let access = EXPLICIT_ACCESS_W {
343 grfAccessPermissions: 0x001F_01FF, 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 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
388fn 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 #[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
417fn 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]); hasher.update(passphrase.as_bytes());
428 hex::encode(hasher.finalize())
429}
430
431fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
433 DERIVED_KEYS.lock().ok()?.get(id).cloned()
434}
435
436fn 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
443pub fn clear_derived_key_cache() {
449 if let Ok(mut keys) = DERIVED_KEYS.lock() {
450 keys.clear();
451 }
452}
453
454fn throttle_state_path(repo_path: &str) -> std::path::PathBuf {
456 std::path::PathBuf::from(format!("{}.throttle", repo_path))
457}
458
459#[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
478fn 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
501fn 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 let _ = restrict_to_owner(&path);
519 }
520 }
521}
522
523fn 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 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 tracker.lockout_until = None;
554 tracker.count = 0;
555 store_throttle(repo_path, &tracker);
556 }
557
558 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
575fn 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 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
594fn clear_failed_attempts(repo_path: &str) {
596 if let Ok(_serialize) = FAILED_ATTEMPTS.lock() {
597 let _ = fs::remove_file(throttle_state_path(repo_path));
600 }
601}
602
603#[derive(ZeroizeOnDrop)]
605#[allow(unused_assignments)]
606pub struct EncryptionKey {
607 key_bytes: [u8; KEY_SIZE],
608 #[zeroize(skip)]
610 salt: [u8; SALT_SIZE],
611}
612
613impl EncryptionKey {
614 pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
616 #[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 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 pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
661 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 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 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 if encrypted_data.len() == SALT_SIZE + 1 {
700 let key = Self::from_passphrase(passphrase, salt)?;
702 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 let key = Self::from_passphrase(passphrase, salt)?;
715
716 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 use subtle::ConstantTimeEq;
725 if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
726 #[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 std::thread::sleep(std::time::Duration::from_millis(100));
735 return Err("Invalid passphrase".to_string());
736 }
737
738 clear_failed_attempts(&key_file_str);
740 Ok(key)
741 }
742
743 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 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 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 let _ = restrict_dir_to_owner(parent);
765 }
766
767 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 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_to_owner(&temp_file)?;
786
787 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 fn as_bytes(&self) -> &[u8; KEY_SIZE] {
801 &self.key_bytes
802 }
803}
804
805const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
808
809pub struct EncryptionEngine {
812 cipher: Aes256Gcm,
813 nonce_counter: AtomicU64,
815}
816
817impl EncryptionEngine {
818 pub fn new(key: &EncryptionKey) -> Result<Self, String> {
820 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 #[allow(deprecated)]
839 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
840 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 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 let ciphertext = self
877 .cipher
878 .encrypt(nonce, plaintext)
879 .map_err(|e| format!("Encryption failed: {}", e))?;
880
881 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 #[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 let version = encrypted[0];
899 if version != ENCRYPTION_VERSION {
900 return Err(format!("Unsupported encryption version: {}", version));
901 }
902
903 let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
905 let nonce = Nonce::from_slice(nonce_bytes);
906
907 let ciphertext = &encrypted[1 + NONCE_SIZE..];
909
910 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
920impl CachedPassphrase {
922 fn is_valid(&self) -> bool {
924 SystemTime::now() < self.expires_at
925 }
926}
927
928pub 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
945pub 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 cache.remove(repo_path);
955 }
956 }
957 }
958 None
959}
960
961pub fn clear_passphrase_cache() {
963 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
964 cache.clear();
965 }
966}
967
968pub fn clear_cached_passphrase(repo_path: &str) {
970 if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
971 cache.remove(repo_path);
972 }
973}
974
975fn get_passphrase_non_interactive(
981 repo_path: &str,
982 config: &EncryptionConfig,
983) -> Option<Zeroizing<String>> {
984 if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
986 if !pass.is_empty() {
987 return Some(Zeroizing::new(pass));
988 }
989 }
990
991 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 if config.cache_timeout_secs > 0 {
1006 if let Some(cached) = get_cached_passphrase(repo_path) {
1007 return Some(cached);
1008 }
1009 }
1010
1011 if let Some(from_agent) = crate::crypto::agent::get(repo_path) {
1017 return Some(from_agent);
1018 }
1019
1020 None
1021}
1022
1023pub fn prompt_for_passphrase(
1029 repo_path: &str,
1030 config: &EncryptionConfig,
1031 prompt_text: &str,
1032) -> Result<Zeroizing<String>, String> {
1033 if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
1035 return Ok(pass);
1036 }
1037
1038 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 rpassword::prompt_password(prompt_text)
1050 .map(Zeroizing::new)
1051 .map_err(|e| format!("Failed to read passphrase: {}", e))
1052}
1053
1054const MIN_PASSPHRASE_LENGTH: usize = 16;
1056
1057fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
1063 #[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 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
1097pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
1101 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 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 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 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
1148pub struct EncryptionManager {
1150 config: EncryptionConfig,
1151 engine: Option<EncryptionEngine>,
1152 repo_path: Option<String>,
1153 unlock_error: Option<String>,
1160}
1161
1162impl EncryptionManager {
1163 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 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 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 pub fn is_encrypted_payload(data: &[u8]) -> bool {
1234 data.first() == Some(&ENCRYPTION_VERSION)
1235 }
1236
1237 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 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 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 self.engine = Some(EncryptionEngine::new(&key)?);
1278
1279 Ok(())
1280 }
1281
1282 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 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 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 self.initialize(&actual_passphrase)?;
1342
1343 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 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 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 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 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 pub fn is_enabled(&self) -> bool {
1404 self.config.enabled
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 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 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 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 let encrypted = engine.encrypt(plaintext).unwrap();
1470
1471 assert_ne!(encrypted.as_slice(), plaintext);
1473
1474 let decrypted = engine.decrypt(&encrypted).unwrap();
1476
1477 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 let encrypted1 = engine.encrypt(plaintext).unwrap();
1493 let encrypted2 = engine.encrypt(plaintext).unwrap();
1494
1495 assert_ne!(encrypted1, encrypted2);
1497
1498 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 let len = encrypted.len();
1516 encrypted[len - 1] ^= 0x01;
1517
1518 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 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_passphrase_cache();
1546
1547 assert!(get_cached_passphrase(repo_path).is_none());
1549
1550 cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
1552
1553 assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
1555
1556 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_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 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_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
1599 cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
1600
1601 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, ..Default::default()
1625 };
1626
1627 let mut manager = EncryptionManager::new(config);
1628 let passphrase = "test-cache-manager-pass";
1629
1630 manager
1632 .initialize_with_cache(repo_str, Some(passphrase))
1633 .unwrap();
1634
1635 assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
1637
1638 let mut manager2 = EncryptionManager::new(manager.config.clone());
1640 manager2.initialize_with_cache(repo_str, None).unwrap();
1641
1642 clear_passphrase_cache();
1644 let _ = fs::remove_file(&key_file);
1645 }
1646
1647 #[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 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 assert!(
1675 check_rate_limit(&key).is_err(),
1676 "a locked-out key should be refused"
1677 );
1678
1679 clear_failed_attempts(&key);
1681 assert_eq!(load_throttle(&key).count, 0);
1682 assert!(check_rate_limit(&key).is_ok());
1683 }
1684
1685 #[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 #[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 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 assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
1719
1720 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 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 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 #[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 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 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 #[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 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 #[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 assert_eq!(fs::read(&path).unwrap(), b"secret");
1872 }
1873}