-
Notifications
You must be signed in to change notification settings - Fork 3.6k
fix(keyring): stop concurrent writers wiping the shared secrets file #5436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f597778
66bd6b3
e566e86
51d691c
b2ec573
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,11 +13,10 @@ use std::collections::HashMap; | |
| use std::path::{Path, PathBuf}; | ||
| use std::sync::OnceLock; | ||
|
|
||
| use parking_lot::Mutex; | ||
|
|
||
| use crate::openhuman::security::keyring::backend::KeyringBackend; | ||
| use crate::openhuman::security::keyring::crypto::{self, KEY_LEN}; | ||
| use crate::openhuman::security::keyring::error::KeyringError; | ||
| use crate::openhuman::security::keyring::file_store; | ||
| use crate::openhuman::security::keyring::store::BackendKind; | ||
|
|
||
| const KEYCHAIN_SERVICE: &str = "openhuman"; | ||
|
|
@@ -174,18 +173,24 @@ fn master_key() -> Option<&'static [u8; KEY_LEN]> { | |
|
|
||
| // ── Backend ────────────────────────────────────────────────────────────────── | ||
|
|
||
| /// Every secret in one ChaCha20-Poly1305 file. | ||
| /// | ||
| /// Mutations are a read → decrypt → modify → encrypt → write cycle over the | ||
| /// whole set, guarded by the cross-process advisory lock in | ||
| /// [`file_store::lock_for_write`]. An in-process mutex would not do: more than | ||
| /// one process routinely addresses the same workspace (a desktop core and a | ||
| /// `medulla` TUI embedding the same core), and the later writer's snapshot — | ||
| /// read before the earlier writer landed — silently drops the earlier secret. | ||
| pub struct EncryptedFileBackend { | ||
| path: PathBuf, | ||
| workspace_dir: PathBuf, | ||
| mutex: Mutex<()>, | ||
| } | ||
|
|
||
| impl EncryptedFileBackend { | ||
| pub fn new(workspace_dir: &Path) -> Self { | ||
| Self { | ||
| path: workspace_dir.join(SECRETS_FILENAME), | ||
| workspace_dir: workspace_dir.to_path_buf(), | ||
| mutex: Mutex::new(()), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -230,42 +235,13 @@ impl EncryptedFileBackend { | |
| key: &[u8; KEY_LEN], | ||
| map: &HashMap<String, String>, | ||
| ) -> Result<(), KeyringError> { | ||
| if let Some(parent) = self.path.parent() { | ||
| std::fs::create_dir_all(parent).map_err(|e| KeyringError::MigrationReadFailed { | ||
| path: parent.display().to_string(), | ||
| source: e, | ||
| })?; | ||
| } | ||
|
|
||
| let json = serde_json::to_vec(map) | ||
| .map_err(|e| KeyringError::Backend(format!("failed to serialize secrets: {e}")))?; | ||
|
|
||
| let blob = crypto::chacha20_encrypt(key, &json) | ||
| .map_err(|e| KeyringError::Backend(format!("encryption failed: {e}")))?; | ||
|
|
||
| let tmp_path = self.path.with_extension("enc.tmp"); | ||
| std::fs::write(&tmp_path, &blob).map_err(|e| KeyringError::MigrationDeleteFailed { | ||
| path: tmp_path.display().to_string(), | ||
| source: e, | ||
| })?; | ||
|
|
||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| let perms = std::fs::Permissions::from_mode(0o600); | ||
| if let Err(e) = std::fs::set_permissions(&tmp_path, perms) { | ||
| log::warn!("[keyring:encrypted_file] could not set 0600 on temp file: {e}"); | ||
| } | ||
| } | ||
|
|
||
| std::fs::rename(&tmp_path, &self.path).map_err(|e| { | ||
| KeyringError::MigrationDeleteFailed { | ||
| path: self.path.display().to_string(), | ||
| source: e, | ||
| } | ||
| })?; | ||
|
|
||
| Ok(()) | ||
| file_store::write_atomic(&self.path, &blob) | ||
| } | ||
|
|
||
| fn migrate_legacy_dev_keychain( | ||
|
|
@@ -320,17 +296,10 @@ impl EncryptedFileBackend { | |
| Ok(map) | ||
| } | ||
|
|
||
| /// Move an undecryptable / unparseable secrets file aside so the next call | ||
| /// starts fresh without destroying the bytes. | ||
| fn handle_corruption(&self) { | ||
| let ts = chrono::Utc::now().format("%Y%m%d%H%M%S"); | ||
| let corrupt_path = self.path.with_extension(format!("enc.corrupt.{ts}")); | ||
| if let Err(e) = std::fs::rename(&self.path, &corrupt_path) { | ||
| log::error!("[keyring:encrypted_file] could not rename corrupt file: {e}"); | ||
| } else { | ||
| log::warn!( | ||
| "[keyring:encrypted_file] corrupt file renamed to {}", | ||
| corrupt_path.display() | ||
| ); | ||
| } | ||
| file_store::quarantine_corrupt(&self.path, "enc"); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -339,7 +308,11 @@ impl KeyringBackend for EncryptedFileBackend { | |
| let Some(key) = master_key() else { | ||
| return Ok(None); | ||
| }; | ||
| let _guard = self.mutex.lock(); | ||
| // `read_map` can mutate the filesystem: it migrates a missing file and | ||
| // quarantines corrupt ciphertext. Hold the same lock as writers for | ||
| // either case so a delayed quarantine cannot rename a replacement a | ||
| // concurrent `set` just published. | ||
| let _guard = file_store::lock_for_write(&self.path)?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a production desktop using Useful? React with 👍 / 👎. |
||
| let map = self.read_map(key)?; | ||
| Ok(map.get(namespaced_key).cloned()) | ||
| } | ||
|
|
@@ -350,7 +323,9 @@ impl KeyringBackend for EncryptedFileBackend { | |
| "master key unavailable — cannot store secrets".to_string(), | ||
| )); | ||
| }; | ||
| let _guard = self.mutex.lock(); | ||
| // Held across the read as well as the write: taking it around the write | ||
| // alone would still let a stale map overwrite a concurrent one. | ||
| let _guard = file_store::lock_for_write(&self.path)?; | ||
| let mut map = self.read_map(key)?; | ||
| map.insert(namespaced_key.to_string(), value.to_string()); | ||
| self.write_map(key, &map) | ||
|
|
@@ -360,7 +335,7 @@ impl KeyringBackend for EncryptedFileBackend { | |
| let Some(key) = master_key() else { | ||
| return Ok(()); | ||
| }; | ||
| let _guard = self.mutex.lock(); | ||
| let _guard = file_store::lock_for_write(&self.path)?; | ||
| let mut map = self.read_map(key)?; | ||
| if map.remove(namespaced_key).is_some() { | ||
| self.write_map(key, &map)?; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The error text claims the file was moved even when the quarantine fails.
quarantine_corruptreturnsNonewhen the rename fails, and that result is discarded. The error message then states "it was moved aside rather than overwritten" while the corrupt file is still in place. A user who follows the message looks for a.corrupt.file that does not exist.Branch on the returned
Optionand report the actual outcome.🛠️ Proposed fix
Err(e) if for_write => { - file_store::quarantine_corrupt(&self.path, "json"); - Err(KeyringError::Backend(format!( - "dev-keychain.json at {} could not be parsed ({e}); it was moved aside \ - rather than overwritten", - self.path.display() - ))) + let outcome = match file_store::quarantine_corrupt(&self.path, "json") { + Some(target) => format!("it was moved to {}", target.display()), + None => "it could not be moved aside and is still in place".to_string(), + }; + Err(KeyringError::Backend(format!( + "dev-keychain.json at {} could not be parsed ({e}); {outcome} \ + rather than overwritten", + self.path.display() + ))) }The existing test asserts on the substring "moved aside", so update
file_backend_set_refuses_to_overwrite_an_unparseable_fileinsrc/openhuman/security/keyring/tests.rsto match the new wording.🤖 Prompt for AI Agents