mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 00:09:52 +00:00
store connection secrets in system keyring
This commit is contained in:
@@ -3,6 +3,9 @@ use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::connection_secrets::{
|
||||
load_connections_from_file, save_connections_to_file, KeyringConnectionSecretStore,
|
||||
};
|
||||
use crate::commands::query_cancel::RunningQueries;
|
||||
use crate::db;
|
||||
use crate::db::ssh_tunnel::TunnelManager;
|
||||
@@ -230,21 +233,15 @@ pub async fn save_connections(
|
||||
configs: Vec<ConnectionConfig>,
|
||||
) -> Result<(), String> {
|
||||
let path = connections_file(&app)?;
|
||||
let json = serde_json::to_string_pretty(&configs).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, json).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
let store = KeyringConnectionSecretStore;
|
||||
save_connections_to_file(&path, &configs, &store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, String> {
|
||||
let path = connections_file(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let configs: Vec<ConnectionConfig> =
|
||||
serde_json::from_str(&json).map_err(|e| e.to_string())?;
|
||||
Ok(configs)
|
||||
let store = KeyringConnectionSecretStore;
|
||||
load_connections_from_file(&path, &store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) const MAIN_PASSWORD_KEY: &str = "password";
|
||||
pub(super) const SSH_PASSWORD_KEY: &str = "ssh_password";
|
||||
pub(super) const CONNECTION_STRING_KEY: &str = "connection_string";
|
||||
|
||||
const KEYRING_SERVICE: &str = "dev.dbx.connections";
|
||||
|
||||
pub(super) trait ConnectionSecretStore {
|
||||
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>;
|
||||
fn get_secret(&self, connection_id: &str, key: &str) -> Result<Option<String>, String>;
|
||||
fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
pub(super) struct KeyringConnectionSecretStore;
|
||||
|
||||
impl ConnectionSecretStore for KeyringConnectionSecretStore {
|
||||
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String> {
|
||||
let entry = keyring_entry(connection_id, key, "create")?;
|
||||
entry
|
||||
.set_password(secret)
|
||||
.map_err(|err| keyring_error("store", connection_id, key, err))
|
||||
}
|
||||
|
||||
fn get_secret(&self, connection_id: &str, key: &str) -> Result<Option<String>, String> {
|
||||
let entry = keyring_entry(connection_id, key, "create")?;
|
||||
match entry.get_password() {
|
||||
Ok(secret) => Ok(Some(secret)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(keyring_error("read", connection_id, key, err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String> {
|
||||
let entry = keyring_entry(connection_id, key, "create")?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(keyring_error("delete", connection_id, key, err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_connections_to_file(
|
||||
path: &Path,
|
||||
configs: &[ConnectionConfig],
|
||||
store: &impl ConnectionSecretStore,
|
||||
) -> Result<(), String> {
|
||||
delete_removed_connection_secrets(path, configs, store)?;
|
||||
for config in configs {
|
||||
persist_secret(store, &config.id, MAIN_PASSWORD_KEY, &config.password)?;
|
||||
persist_secret(store, &config.id, SSH_PASSWORD_KEY, &config.ssh_password)?;
|
||||
persist_optional_secret(
|
||||
store,
|
||||
&config.id,
|
||||
CONNECTION_STRING_KEY,
|
||||
config.connection_string.as_deref(),
|
||||
)?;
|
||||
}
|
||||
|
||||
write_sanitized_connections(path, configs)
|
||||
}
|
||||
|
||||
pub(super) fn load_connections_from_file(
|
||||
path: &Path,
|
||||
store: &impl ConnectionSecretStore,
|
||||
) -> Result<Vec<ConnectionConfig>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut configs = read_connections(path)?;
|
||||
let mut needs_rewrite = false;
|
||||
for config in &mut configs {
|
||||
if config.password.is_empty() {
|
||||
if let Some(secret) = store.get_secret(&config.id, MAIN_PASSWORD_KEY)? {
|
||||
config.password = secret;
|
||||
}
|
||||
} else {
|
||||
store.set_secret(&config.id, MAIN_PASSWORD_KEY, &config.password)?;
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if config.ssh_password.is_empty() {
|
||||
if let Some(secret) = store.get_secret(&config.id, SSH_PASSWORD_KEY)? {
|
||||
config.ssh_password = secret;
|
||||
}
|
||||
} else {
|
||||
store.set_secret(&config.id, SSH_PASSWORD_KEY, &config.ssh_password)?;
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
match config
|
||||
.connection_string
|
||||
.as_deref()
|
||||
.filter(|secret| !secret.is_empty())
|
||||
{
|
||||
Some(secret) => {
|
||||
store.set_secret(&config.id, CONNECTION_STRING_KEY, secret)?;
|
||||
needs_rewrite = true;
|
||||
}
|
||||
None => {
|
||||
if let Some(secret) = store.get_secret(&config.id, CONNECTION_STRING_KEY)? {
|
||||
config.connection_string = Some(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needs_rewrite {
|
||||
write_sanitized_connections(path, &configs)?;
|
||||
}
|
||||
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
fn delete_removed_connection_secrets(
|
||||
path: &Path,
|
||||
configs: &[ConnectionConfig],
|
||||
store: &impl ConnectionSecretStore,
|
||||
) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let previous = match read_connections(path) {
|
||||
Ok(configs) => configs,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let current_ids: HashSet<&str> = configs.iter().map(|config| config.id.as_str()).collect();
|
||||
for config in previous {
|
||||
if current_ids.contains(config.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
store.delete_secret(&config.id, MAIN_PASSWORD_KEY)?;
|
||||
store.delete_secret(&config.id, SSH_PASSWORD_KEY)?;
|
||||
store.delete_secret(&config.id, CONNECTION_STRING_KEY)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_secret(
|
||||
store: &impl ConnectionSecretStore,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
secret: &str,
|
||||
) -> Result<(), String> {
|
||||
if secret.is_empty() {
|
||||
store.delete_secret(connection_id, key)
|
||||
} else {
|
||||
store.set_secret(connection_id, key, secret)
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_optional_secret(
|
||||
store: &impl ConnectionSecretStore,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
secret: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
match secret.filter(|secret| !secret.is_empty()) {
|
||||
Some(secret) => store.set_secret(connection_id, key, secret),
|
||||
None => store.delete_secret(connection_id, key),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_connections(path: &Path) -> Result<Vec<ConnectionConfig>, String> {
|
||||
let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_sanitized_connections(path: &Path, configs: &[ConnectionConfig]) -> Result<(), String> {
|
||||
let sanitized = sanitize_connections(configs);
|
||||
let json = serde_json::to_string_pretty(&sanitized).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec<ConnectionConfig> {
|
||||
configs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut config| {
|
||||
config.password.clear();
|
||||
config.ssh_password.clear();
|
||||
config.connection_string = None;
|
||||
config
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keyring_entry(connection_id: &str, key: &str, action: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, &secret_account(connection_id, key))
|
||||
.map_err(|err| keyring_error(action, connection_id, key, err))
|
||||
}
|
||||
|
||||
fn secret_account(connection_id: &str, key: &str) -> String {
|
||||
format!("connection:{connection_id}:{key}")
|
||||
}
|
||||
|
||||
fn keyring_error(action: &str, connection_id: &str, key: &str, err: keyring::Error) -> String {
|
||||
format!("Failed to {action} saved {key} for connection {connection_id}: {err}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
load_connections_from_file, save_connections_to_file, ConnectionSecretStore,
|
||||
CONNECTION_STRING_KEY, MAIN_PASSWORD_KEY, SSH_PASSWORD_KEY,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemorySecretStore {
|
||||
values: RefCell<HashMap<String, String>>,
|
||||
deleted: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MemorySecretStore {
|
||||
fn set_existing(&self, connection_id: &str, key: &str, value: &str) {
|
||||
self.values
|
||||
.borrow_mut()
|
||||
.insert(secret_key(connection_id, key), value.to_string());
|
||||
}
|
||||
|
||||
fn get_existing(&self, connection_id: &str, key: &str) -> Option<String> {
|
||||
self.values
|
||||
.borrow()
|
||||
.get(&secret_key(connection_id, key))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn was_deleted(&self, connection_id: &str, key: &str) -> bool {
|
||||
self.deleted
|
||||
.borrow()
|
||||
.contains(&secret_key(connection_id, key))
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectionSecretStore for MemorySecretStore {
|
||||
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String> {
|
||||
self.values
|
||||
.borrow_mut()
|
||||
.insert(secret_key(connection_id, key), secret.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_secret(&self, connection_id: &str, key: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.values
|
||||
.borrow()
|
||||
.get(&secret_key(connection_id, key))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String> {
|
||||
self.values
|
||||
.borrow_mut()
|
||||
.remove(&secret_key(connection_id, key));
|
||||
self.deleted
|
||||
.borrow_mut()
|
||||
.push(secret_key(connection_id, key));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_key(connection_id: &str, key: &str) -> String {
|
||||
format!("{connection_id}:{key}")
|
||||
}
|
||||
|
||||
fn temp_connections_file(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dbx-connection-secrets-test-{}-{name}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join("connections.json")
|
||||
}
|
||||
|
||||
fn connection(id: &str, password: &str, ssh_password: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: id.to_string(),
|
||||
name: format!("{id} connection"),
|
||||
db_type: DatabaseType::Postgres,
|
||||
driver_profile: None,
|
||||
driver_label: None,
|
||||
url_params: None,
|
||||
host: "localhost".to_string(),
|
||||
port: 5432,
|
||||
username: "postgres".to_string(),
|
||||
password: password.to_string(),
|
||||
database: Some("postgres".to_string()),
|
||||
color: None,
|
||||
ssh_enabled: !ssh_password.is_empty(),
|
||||
ssh_host: String::new(),
|
||||
ssh_port: 22,
|
||||
ssh_user: String::new(),
|
||||
ssh_password: ssh_password.to_string(),
|
||||
ssh_key_path: String::new(),
|
||||
ssh_expose_lan: false,
|
||||
ssl: false,
|
||||
connection_string: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_configs(path: &Path) -> Vec<ConnectionConfig> {
|
||||
let json = std::fs::read_to_string(path).unwrap();
|
||||
serde_json::from_str(&json).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_moves_passwords_to_secret_store_and_redacts_file() {
|
||||
let path = temp_connections_file("save-redacts");
|
||||
let store = MemorySecretStore::default();
|
||||
let configs = vec![connection("main", "db-secret", "ssh-secret")];
|
||||
|
||||
save_connections_to_file(&path, &configs, &store).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.get_existing("main", MAIN_PASSWORD_KEY).as_deref(),
|
||||
Some("db-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
store.get_existing("main", SSH_PASSWORD_KEY).as_deref(),
|
||||
Some("ssh-secret")
|
||||
);
|
||||
let persisted = read_configs(&path);
|
||||
assert_eq!(persisted[0].password, "");
|
||||
assert_eq!(persisted[0].ssh_password, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_connections_restores_passwords_from_secret_store() {
|
||||
let path = temp_connections_file("load-restores");
|
||||
let store = MemorySecretStore::default();
|
||||
store.set_existing("main", MAIN_PASSWORD_KEY, "db-secret");
|
||||
store.set_existing("main", SSH_PASSWORD_KEY, "ssh-secret");
|
||||
let sanitized = vec![connection("main", "", "")];
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&sanitized).unwrap()).unwrap();
|
||||
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
|
||||
assert_eq!(loaded[0].password, "db-secret");
|
||||
assert_eq!(loaded[0].ssh_password, "ssh-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_connections_migrates_plaintext_passwords_and_rewrites_sanitized_file() {
|
||||
let path = temp_connections_file("migrates-plaintext");
|
||||
let store = MemorySecretStore::default();
|
||||
let legacy = vec![connection("legacy", "plain-db", "plain-ssh")];
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap();
|
||||
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
|
||||
assert_eq!(loaded[0].password, "plain-db");
|
||||
assert_eq!(loaded[0].ssh_password, "plain-ssh");
|
||||
assert_eq!(
|
||||
store.get_existing("legacy", MAIN_PASSWORD_KEY).as_deref(),
|
||||
Some("plain-db")
|
||||
);
|
||||
assert_eq!(
|
||||
store.get_existing("legacy", SSH_PASSWORD_KEY).as_deref(),
|
||||
Some("plain-ssh")
|
||||
);
|
||||
let persisted = read_configs(&path);
|
||||
assert_eq!(persisted[0].password, "");
|
||||
assert_eq!(persisted[0].ssh_password, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_deletes_secrets_for_removed_connections() {
|
||||
let path = temp_connections_file("deletes-removed");
|
||||
let store = MemorySecretStore::default();
|
||||
let previous = vec![connection("old", "", ""), connection("kept", "", "")];
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&previous).unwrap()).unwrap();
|
||||
store.set_existing("old", MAIN_PASSWORD_KEY, "old-db");
|
||||
store.set_existing("old", SSH_PASSWORD_KEY, "old-ssh");
|
||||
store.set_existing("kept", MAIN_PASSWORD_KEY, "kept-db");
|
||||
|
||||
save_connections_to_file(&path, &[connection("kept", "new-db", "")], &store).unwrap();
|
||||
|
||||
assert!(store.was_deleted("old", MAIN_PASSWORD_KEY));
|
||||
assert!(store.was_deleted("old", SSH_PASSWORD_KEY));
|
||||
assert_eq!(
|
||||
store.get_existing("kept", MAIN_PASSWORD_KEY).as_deref(),
|
||||
Some("new-db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_connections_moves_connection_string_to_secret_store_and_restores_it() {
|
||||
let path = temp_connections_file("connection-string");
|
||||
let store = MemorySecretStore::default();
|
||||
let mut config = connection("mongo", "", "");
|
||||
config.db_type = DatabaseType::MongoDb;
|
||||
config.connection_string = Some("mongodb://user:secret@localhost/app".to_string());
|
||||
|
||||
save_connections_to_file(&path, &[config], &store).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.get_existing("mongo", CONNECTION_STRING_KEY)
|
||||
.as_deref(),
|
||||
Some("mongodb://user:secret@localhost/app")
|
||||
);
|
||||
let persisted = read_configs(&path);
|
||||
assert_eq!(persisted[0].connection_string, None);
|
||||
|
||||
let loaded = load_connections_from_file(&path, &store).unwrap();
|
||||
assert_eq!(
|
||||
loaded[0].connection_string.as_deref(),
|
||||
Some("mongodb://user:secret@localhost/app")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ai;
|
||||
pub mod connection;
|
||||
mod connection_secrets;
|
||||
pub mod history;
|
||||
pub mod mongo_cmd;
|
||||
pub mod query;
|
||||
|
||||
@@ -249,6 +249,7 @@ mod tests {
|
||||
ssh_user: String::new(),
|
||||
ssh_password: String::new(),
|
||||
ssh_key_path: String::new(),
|
||||
ssh_expose_lan: false,
|
||||
ssl: false,
|
||||
connection_string: None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user