Skip to content

Commit 5a4164f

Browse files
committed
fix: prevent race conditions in encrypted backend with write locks, track keychain keys, and propagate environment override status in operations
1 parent eeaad65 commit 5a4164f

3 files changed

Lines changed: 91 additions & 30 deletions

File tree

src/credentials/encrypted.rs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use std::collections::HashMap;
1919
use std::fs;
2020
use std::io::Write;
2121
use std::path::{Path, PathBuf};
22-
use std::sync::RwLock;
22+
use std::sync::{Mutex, RwLock};
2323

2424
/// Encrypted credential entry
2525
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -46,7 +46,19 @@ pub struct EncryptedFileBackend {
4646
cipher: Aes256Gcm,
4747
/// Salt used for key derivation (stored in file for decryption on restart)
4848
salt: [u8; 16],
49+
/// Plaintext read-cache.
50+
///
51+
/// Populated lazily on `get()` and kept in sync on `store()`/`remove()`.
4952
cache: RwLock<HashMap<String, String>>,
53+
/// Serializes all mutations to the encrypted file.
54+
///
55+
/// BUG FIX: without this lock, two concurrent `store()` / `remove()` calls
56+
/// both read the file into separate in-memory copies, modify their own copy,
57+
/// and write back. The second write silently drops the first writer's change.
58+
/// Holding this mutex across the entire read-modify-write cycle prevents the
59+
/// TOCTOU race. The read cache (`self.cache`) is updated while the mutex is
60+
/// still held, so cache and disk are always consistent.
61+
write_lock: Mutex<()>,
5062
}
5163

5264
impl EncryptedFileBackend {
@@ -66,6 +78,7 @@ impl EncryptedFileBackend {
6678
.map_err(|_| Error::Credential("Invalid encryption key length".into()))?,
6779
salt,
6880
cache: RwLock::new(HashMap::new()),
81+
write_lock: Mutex::new(()),
6982
})
7083
}
7184

@@ -311,20 +324,27 @@ impl EncryptedFileBackend {
311324

312325
impl CredentialBackend for EncryptedFileBackend {
313326
fn store(&self, key: &str, value: &str) -> Result<()> {
327+
let _guard = self
328+
.write_lock
329+
.lock()
330+
.map_err(|_| Error::Credential("Encrypted file write lock poisoned".into()))?;
331+
314332
let mut store = self.load_store()?;
315333
let encrypted = self.encrypt(value)?;
316334
store.entries.insert(key.to_string(), encrypted);
317335
self.save_store(store.entries)?;
318336

319-
let mut cache = self.cache.write_recovered()?;
320-
cache.insert(key.to_string(), value.to_string());
337+
// Update cache while write_lock is held so cache and disk are always consistent.
338+
self.cache
339+
.write_recovered()?
340+
.insert(key.to_string(), value.to_string());
321341

322342
debug!("Credential stored in encrypted file: {key}");
323343
Ok(())
324344
}
325345

326346
fn get(&self, key: &str) -> Result<Option<String>> {
327-
// Check cache first
347+
// Check cache first (no write_lock needed for reads)
328348
{
329349
let cache = self.cache.read_recovered()?;
330350
if let Some(value) = cache.get(key) {
@@ -349,6 +369,11 @@ impl CredentialBackend for EncryptedFileBackend {
349369
}
350370

351371
fn remove(&self, key: &str) -> Result<()> {
372+
let _guard = self
373+
.write_lock
374+
.lock()
375+
.map_err(|_| Error::Credential("Encrypted file write lock poisoned".into()))?;
376+
352377
let mut store = self.load_store()?;
353378
store.entries.remove(key);
354379
self.save_store(store.entries)?;
@@ -459,4 +484,28 @@ mod tests {
459484
let key4 = EncryptedFileBackend::derive_key("password123", &salt2).unwrap();
460485
assert_ne!(key1, key4);
461486
}
487+
488+
/// Regression test for the concurrent write TOCTOU bug.
489+
/// Two threads writing different keys must both survive.
490+
#[test]
491+
fn test_concurrent_store_no_lost_writes() {
492+
use std::sync::Arc;
493+
use std::thread;
494+
495+
let temp = tempdir().unwrap();
496+
let path = temp.path().join("credentials.enc.json");
497+
let backend = Arc::new(EncryptedFileBackend::with_password(path, "password").unwrap());
498+
499+
let b1 = Arc::clone(&backend);
500+
let b2 = Arc::clone(&backend);
501+
502+
let t1 = thread::spawn(move || b1.store("key_a", "value_a").unwrap());
503+
let t2 = thread::spawn(move || b2.store("key_b", "value_b").unwrap());
504+
505+
t1.join().unwrap();
506+
t2.join().unwrap();
507+
508+
assert_eq!(backend.get("key_a").unwrap(), Some("value_a".to_string()));
509+
assert_eq!(backend.get("key_b").unwrap(), Some("value_b".to_string()));
510+
}
462511
}

src/credentials/keychain.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
use super::CredentialBackend;
44
use crate::error::{Error, Result};
5-
// Removed: use keyring::use_native_store;
65
use keyring_core::{Entry, Error as KeyringError};
76
use log::{debug, warn};
87
use std::collections::HashSet;
@@ -13,7 +12,7 @@ static NATIVE_STORE_INIT: OnceLock<()> = OnceLock::new();
1312
/// OS Keychain backend for secure credential storage
1413
pub struct KeychainBackend {
1514
service_name: String,
16-
/// Cache of known keys (keychain doesn't support listing)
15+
/// Cache of known keys (keychain doesn't support listing).
1716
known_keys: RwLock<HashSet<String>>,
1817
}
1918

@@ -88,6 +87,7 @@ impl CredentialBackend for KeychainBackend {
8887
fn get(&self, key: &str) -> Result<Option<String>> {
8988
match self.get_entry(key)?.get_password() {
9089
Ok(password) => {
90+
self.track_key(key);
9191
debug!("Credential retrieved from keychain: {key}");
9292
Ok(Some(password))
9393
}

src/manager/operations.rs

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,35 +35,38 @@ impl<S: StorageBackend + 'static, Schema: SettingsSchema> SettingsManager<S, Sch
3535
&self,
3636
key: &str,
3737
metadata: &SettingMetadata,
38-
) -> Result<Option<Value>> {
38+
) -> Result<Option<(Value, bool)>> {
3939
// For secrets, check keyring first
4040
if metadata.is_secret() {
4141
// Check env var override for secrets if enabled
4242
if self.config.env_overrides_secrets
4343
&& let Some(env_value) = self.get_env_override(key)
4444
{
45-
return Ok(Some(env_value));
45+
return Ok(Some((env_value, true)));
4646
}
4747

4848
// Try retrieving from keyring
4949
if let Ok(Some(secret_value)) = self.get_credential_with_profile(key) {
50-
return Ok(Some(Value::String(secret_value)));
50+
return Ok(Some((Value::String(secret_value), false)));
5151
}
5252

5353
// Secret not found, use default
54-
return Ok(Some(metadata.default.clone()));
54+
return Ok(Some((metadata.default.clone(), false)));
5555
}
5656

5757
// Not a secret - check cache (with env override support)
5858
if let Some(env_value) = self.get_env_override(key) {
59-
return Ok(Some(env_value));
59+
return Ok(Some((env_value, true)));
6060
}
6161

6262
let Some((category, setting_name)) = Self::parse_setting_key(key) else {
6363
return Ok(None);
6464
};
6565

66-
self.settings_cache.get_value(category, setting_name, key)
66+
Ok(self
67+
.settings_cache
68+
.get_value(category, setting_name, key)?
69+
.map(|v| (v, false)))
6770
}
6871

6972
/// Helper to get a setting value (non-credential version)
@@ -72,18 +75,22 @@ impl<S: StorageBackend + 'static, Schema: SettingsSchema> SettingsManager<S, Sch
7275
&self,
7376
key: &str,
7477
_metadata: &SettingMetadata,
75-
) -> Result<Option<Value>> {
78+
) -> Result<Option<(Value, bool)>> {
7679
// Check env override first
7780
if let Some(env_value) = self.get_env_override(key) {
78-
return Ok(Some(env_value));
81+
return Ok(Some((env_value, true)));
7982
}
8083

8184
let Some((category, setting_name)) = Self::parse_setting_key(key) else {
8285
return Ok(None);
8386
};
8487

85-
self.settings_cache.get_value(category, setting_name, key)
88+
Ok(self
89+
.settings_cache
90+
.get_value(category, setting_name, key)?
91+
.map(|v| (v, false)))
8692
}
93+
8794
/// Check if a setting value is overridden by an environment variable
8895
///
8996
/// Returns the parsed value if env var is set and successfully parsed.
@@ -113,20 +120,24 @@ impl<S: StorageBackend + 'static, Schema: SettingsSchema> SettingsManager<S, Sch
113120

114121
for (key, option) in &mut metadata {
115122
if Self::parse_setting_key(key).is_some() {
116-
// Use the helper method to get value (handles secrets and env overrides)
117-
if let Ok(Some(value)) = self.get_value_with_secret_support(key, option) {
118-
option.value = Some(value);
119-
120-
// Mark as env-overridden if applicable
121-
if self.get_env_override(key).is_some() {
122-
option
123-
.metadata
124-
.insert("env_override".to_string(), Value::Bool(true));
125-
debug!("Setting {key} overridden by env var");
123+
match self.get_value_with_secret_support(key, option) {
124+
Ok(Some((value, env_overridden))) => {
125+
option.value = Some(value);
126+
if env_overridden {
127+
option
128+
.metadata
129+
.insert("env_override".to_string(), Value::Bool(true));
130+
debug!("Setting {key} overridden by env var");
131+
}
132+
}
133+
Ok(None) => {
134+
// Fallback to default if helper returns None
135+
option.value = Some(option.default.clone());
136+
}
137+
Err(e) => {
138+
debug!("Failed to read value for {key}: {e}");
139+
option.value = Some(option.default.clone());
126140
}
127-
} else {
128-
// Fallback to default if helper returns None
129-
option.value = Some(option.default.clone());
130141
}
131142
}
132143
}
@@ -189,8 +200,9 @@ impl<S: StorageBackend + 'static, Schema: SettingsSchema> SettingsManager<S, Sch
189200
.get(key)
190201
.ok_or_else(|| Error::SettingNotFound(format!("{category}.{setting_name}")))?;
191202

192-
// Use the helper that handles both secrets and regular settings
203+
// Use the helper that handles both secrets and regular settings.
193204
self.get_value_with_secret_support(key, setting_metadata)?
205+
.map(|(v, _)| v)
194206
.ok_or_else(|| Error::SettingNotFound(format!("{category}.{setting_name}")))
195207
}
196208

@@ -374,7 +386,7 @@ impl<S: StorageBackend + 'static, Schema: SettingsSchema> SettingsManager<S, Sch
374386
for sub_type in sub_types {
375387
categories.push(ExportCategory {
376388
id: sub_type.clone(),
377-
name: sub_type.clone(), // Could be enhanced with display names
389+
name: sub_type.clone(),
378390
category_type: ExportCategoryType::SubSettings,
379391
optional: false,
380392
description: None,

0 commit comments

Comments
 (0)