Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 228 additions & 39 deletions src-tauri/src/commands.rs

Large diffs are not rendered by default.

51 changes: 49 additions & 2 deletions src-tauri/src/persistence.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::models::{ConnectionGroup, ConnectionsFile, SavedConnection};
use crate::models::{ConnectionGroup, ConnectionParams, ConnectionsFile, SavedConnection};
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::fs;
Expand Down Expand Up @@ -72,7 +72,54 @@ pub fn save_connections_file(path: &Path, file: &ConnectionsFile) -> Result<(),
let mut json = serde_json::to_value(&to_save).map_err(|e| e.to_string())?;
preserve_unknown_fields(path, &mut json);
let json = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
fs::write(path, json).map_err(|e| e.to_string())
write_private_file(path, json)
}

/// Writes `contents` so that, on Unix, the file is readable and writable only
/// by the current user (`0o600`). An existing file is tightened to that mode
/// as well. Other platforms fall back to a normal write.
pub fn write_private_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<(), String> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

// `mode` only applies when the file is created, so tighten an existing
// file before the new contents land in it.
if path.exists() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Redundant permission setting in write_private_file

On Unix, this function sets permissions three times:

  1. Line 88-89: set_permissions on existing file
  2. Line 95: mode(0o600) in OpenOptions (creates file with 0600)
  3. Line 100: set_permissions again after write

The mode(0o600) at open already ensures the new file has correct permissions. The first set_permissions is only needed for existing files, and the final one is redundant. Consider simplifying to: set permissions on existing file if needed, then open with mode(0o600) and write.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep the last set_permissions was redundant, dropped it. kept the one before open though since mode() only kicks in when the file is created, so an existing 0644 file still needs tightening before the secrets get written

fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|e| e.to_string())?;
}
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| e.to_string())?;
file.write_all(contents.as_ref()).map_err(|e| e.to_string())
}
#[cfg(not(unix))]
{
fs::write(path, contents).map_err(|e| e.to_string())
}
}

/// Keeps connection secrets out of the config file after a successful keychain
/// write. When the keychain is unavailable, the secrets stay on `params` and
/// `save_in_keychain` is cleared so the file is the source of truth and the
/// save can still succeed.
pub fn apply_secret_persistence(params: &mut ConnectionParams, keychain_stored: bool) {
if !params.save_in_keychain.unwrap_or(false) {
return;
}
if keychain_stored {
params.password = None;
params.ssh_password = None;
params.ssh_key_passphrase = None;
} else {
params.save_in_keychain = Some(false);
}
}

/// Fields of `raw` the current binary's structs do not know: anything present
Expand Down
118 changes: 117 additions & 1 deletion src-tauri/src/persistence_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#[cfg(test)]
mod tests {
use crate::persistence::{load_connections_file, save_connections_file};
use crate::models::{ConnectionParams, ConnectionsFile, SavedConnection};
use crate::persistence::{
apply_secret_persistence, load_connections_file, save_connections_file, write_private_file,
};

#[test]
fn save_preserves_unknown_fields_without_resurrecting_known_changes() {
Expand Down Expand Up @@ -31,4 +34,117 @@ mod tests {
assert!(connection["params"].get("connectionUri").is_none());
assert!(serde_json::from_value::<crate::models::ConnectionsFile>(saved).is_ok());
}

fn connection_with_secret(save_in_keychain: bool) -> SavedConnection {
SavedConnection {
id: "conn-1".to_string(),
name: "prod".to_string(),
params: ConnectionParams {
driver: "mysql".to_string(),
password: Some("db-secret".to_string()),
ssh_password: Some("ssh-secret".to_string()),
save_in_keychain: Some(save_in_keychain),
..ConnectionParams::default()
},
group_id: None,
sort_order: None,
detect_json_in_text_columns: None,
appearance: None,
tag_ids: None,
environment: None,
}
}

#[test]
fn save_connections_file_with_keychain_default_writes_no_password() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("connections.json");
let file = ConnectionsFile {
connections: vec![connection_with_secret(true)],
..ConnectionsFile::default()
};

save_connections_file(&path, &file).unwrap();

let saved = std::fs::read_to_string(&path).unwrap();
assert!(!saved.contains("db-secret"));
assert!(!saved.contains("ssh-secret"));
let parsed: serde_json::Value = serde_json::from_str(&saved).unwrap();
assert_eq!(parsed["connections"][0]["params"]["save_in_keychain"], true);
assert!(parsed["connections"][0]["params"]["password"].is_null());
}

#[test]
fn save_connections_file_keeps_password_when_keychain_is_off() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("connections.json");
let file = ConnectionsFile {
connections: vec![connection_with_secret(false)],
..ConnectionsFile::default()
};

save_connections_file(&path, &file).unwrap();

let saved = std::fs::read_to_string(&path).unwrap();
assert!(saved.contains("db-secret"));
assert!(saved.contains("ssh-secret"));
}

#[test]
fn unavailable_keychain_keeps_the_secret_and_clears_the_flag() {
let mut params = connection_with_secret(true).params;
apply_secret_persistence(&mut params, false);
assert_eq!(params.password.as_deref(), Some("db-secret"));
assert_eq!(params.ssh_password.as_deref(), Some("ssh-secret"));
assert_eq!(params.save_in_keychain, Some(false));
}

#[test]
fn stored_keychain_strips_secrets() {
let mut params = connection_with_secret(true).params;
apply_secret_persistence(&mut params, true);
assert!(params.password.is_none());
assert!(params.ssh_password.is_none());
assert_eq!(params.save_in_keychain, Some(true));
}

#[cfg(unix)]
#[test]
fn write_private_file_creates_mode_0600_with_the_given_content() {
use std::os::unix::fs::PermissionsExt;

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("connections.json");
write_private_file(&path, b"{\"secret\":\"db-secret\"}").unwrap();

let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{\"secret\":\"db-secret\"}"
);
}

#[cfg(unix)]
#[test]
fn write_private_file_tightens_an_existing_0644_file() {
use std::os::unix::fs::PermissionsExt;

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_connections.json");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o644
);

write_private_file(&path, b"new-secret").unwrap();

assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new-secret");
}
}
69 changes: 53 additions & 16 deletions src/components/modals/NewConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { SshConnectionsModal } from "./SshConnectionsModal";
import { K8sConnectionsModal } from "./K8sConnectionsModal";
import { Select } from "../ui/Select";
import { SlotAnchor } from "../ui/SlotAnchor";
import { useAlert } from "../../hooks/useAlert";
import { useDrivers } from "../../hooks/useDrivers";
import { useSettings } from "../../hooks/useSettings";
import { usePluginSlotRegistry } from "../../hooks/usePluginSlotRegistry";
Expand Down Expand Up @@ -266,6 +267,7 @@ export const NewConnectionModal = ({
initialConnection,
}: NewConnectionModalProps) => {
const { t } = useTranslation();
const { showAlert } = useAlert();
const { drivers, refresh: refreshDrivers } = useDrivers();
const { settings, updateSetting } = useSettings();
const { invalidate: invalidateK8sAsync, run: runK8sAsync } = useLatestAsync();
Expand Down Expand Up @@ -366,6 +368,11 @@ export const NewConnectionModal = ({
ssh_enabled: false,
ssh_port: 22,
k8s_enabled: false,
// New connections store secrets in the keychain. An edit starts from the
// saved flag so a slow credential load cannot flip an existing connection.
save_in_keychain: initialConnection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Explicit default for existing connection's save_in_keychain

initialConnection.params.save_in_keychain can be undefined (not set in saved JSON). The checkbox uses !!formData.save_in_keychain which coerces undefined to false, but this is implicit. For clarity and to match the comment "An edit starts from the saved flag", consider: save_in_keychain: initialConnection ? initialConnection.params.save_in_keychain ?? false : true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, switched to ?? false

? (initialConnection.params.save_in_keychain ?? false)
: true,
});
const [selectedDatabasesState, setSelectedDatabasesState] = useState<
string[]
Expand Down Expand Up @@ -1934,6 +1941,7 @@ export const NewConnectionModal = ({
ssh_port: 22,
k8s_enabled: false,
ssm_enabled: false,
save_in_keychain: true,
});
setSelectedDatabasesState([]);
setLoadAllDatabases(true);
Expand Down Expand Up @@ -1977,7 +1985,7 @@ export const NewConnectionModal = ({
ssh_password: undefined,
ssh_key_file: undefined,
ssh_key_passphrase: undefined,
save_in_keychain: false,
save_in_keychain: true,
k8s_enabled: false,
k8s_connection_id: undefined,
k8s_context: undefined,
Expand Down Expand Up @@ -2361,6 +2369,7 @@ export const NewConnectionModal = ({
setTestResult(null);
try {
let savedConnectionId: string;
let persistedInKeychain = params.save_in_keychain;
if (initialConnection) {
// An omitted password means "keep the stored one". While a plugin
// hides the login inputs the empty password is deliberate: it is
Expand All @@ -2369,25 +2378,32 @@ export const NewConnectionModal = ({
delete params.password;
}
if (!params.ssh_password?.trim()) delete params.ssh_password;
await invoke("update_connection", {
const updated = await invoke<{
params?: { save_in_keychain?: boolean };
}>("update_connection", {
id: initialConnection.id,
name,
params,
detectJsonInTextColumns: detectJsonInTextColumns ? true : null,
environment: environment || null,
});
persistedInKeychain = updated?.params?.save_in_keychain;
savedConnectionId = initialConnection.id;
await invoke("set_connection_appearance", {
id: initialConnection.id,
appearance: appearancePayload ?? null,
});
} else {
const saved = await invoke<{ id: string }>("save_connection", {
const saved = await invoke<{
id: string;
params?: { save_in_keychain?: boolean };
}>("save_connection", {
name,
params,
detectJsonInTextColumns: detectJsonInTextColumns ? true : null,
environment: environment || null,
});
persistedInKeychain = saved.params?.save_in_keychain;
savedConnectionId = saved.id;
if (appearancePayload) {
await invoke("set_connection_appearance", {
Expand Down Expand Up @@ -2415,6 +2431,12 @@ export const NewConnectionModal = ({
return;
}

if (params.save_in_keychain && persistedInKeychain === false) {
showAlert(t("newConnection.secretsStoredInPlainText"), {
kind: "warning",
});
}

if (onSave) onSave();
wasSavedRef.current = true;

Expand Down Expand Up @@ -2905,19 +2927,34 @@ export const NewConnectionModal = ({
)}

{/* Keychain */}
<label className="flex items-center gap-2 cursor-pointer select-none w-fit">
<input
type="checkbox"
checked={!!formData.save_in_keychain}
onChange={(e) => {
updateField("save_in_keychain", e.target.checked);
}}
className="accent-accent-primary w-3.5 h-3.5 rounded"
/>
<span className="text-xs text-secondary">
{t("newConnection.saveKeychain")}
</span>
</label>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2 cursor-pointer select-none w-fit">
<input
type="checkbox"
checked={!!formData.save_in_keychain}
onChange={(e) => {
updateField("save_in_keychain", e.target.checked);
}}
aria-describedby={
formData.save_in_keychain
? undefined
: "connection-secrets-plaintext-warning"
}
className="accent-accent-primary w-3.5 h-3.5 rounded"
/>
<span className="text-xs text-secondary">
{t("newConnection.saveKeychain")}
</span>
</label>
{!formData.save_in_keychain && (
<p
id="connection-secrets-plaintext-warning"
className="text-xs text-accent-warning"
>
{t("newConnection.secretsStoredInPlainText")}
</p>
)}
</div>
</>
)}

Expand Down
13 changes: 13 additions & 0 deletions src/components/ssh/SshConnectionsManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,11 @@ export function SshConnectionsManager({
type="checkbox"
id="ssh-keychain-toggle"
checked={!!formData.save_in_keychain}
aria-describedby={
formData.save_in_keychain
? undefined
: "ssh-secrets-plaintext-warning"
}
onChange={(e) => {
setPasswordDirty(true);
setPassphraseDirty(true);
Expand All @@ -562,6 +567,14 @@ export function SshConnectionsManager({
{t("newConnection.saveKeychain")}
</label>
</div>
{!formData.save_in_keychain && (
<p
id="ssh-secrets-plaintext-warning"
className="text-xs text-accent-warning"
>
{t("newConnection.secretsStoredInPlainText")}
</p>
)}

<div className="flex items-center gap-2">
<input
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,7 @@
"sshKeyPassphrasePlaceholder": "Passphrase eingeben, falls der Schlüssel verschlüsselt ist",
"allowSshPrompt": "SSH-Passwort/PIN-Eingabeaufforderung erlauben",
"saveKeychain": "Passwörter im Keychain speichern",
"secretsStoredInPlainText": "Passwörter werden im Klartext in der Verbindungsdatei gespeichert.",
"testConnection": "Verbindung testen",
"save": "Speichern",
"failSave": "Speichern der Verbindung fehlgeschlagen",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,7 @@
"sshKeyPassphrasePlaceholder": "Enter key passphrase if encrypted",
"allowSshPrompt": "Allow SSH password/PIN prompt",
"saveKeychain": "Save passwords in Keychain",
"secretsStoredInPlainText": "Passwords will be stored in plain text in the connection file.",
"testConnection": "Test Connection",
"save": "Save",
"failSave": "Failed to save connection",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,7 @@
"sshKeyPassphrasePlaceholder": "Ingresa la frase de paso si la clave está cifrada",
"allowSshPrompt": "Permitir solicitud de contraseña/PIN de SSH",
"saveKeychain": "Guardar contraseñas en el Llavero",
"secretsStoredInPlainText": "Las contraseñas se guardarán en texto sin formato en el archivo de conexiones.",
"testConnection": "Probar Conexión",
"save": "Guardar",
"failSave": "Error al guardar la conexión",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,7 @@
"sshKeyPassphrasePlaceholder": "Saisissez la phrase secrète si la clé est chiffrée",
"allowSshPrompt": "Autoriser l'invite de mot de passe/PIN SSH",
"saveKeychain": "Enregistrer les mots de passe dans le trousseau",
"secretsStoredInPlainText": "Les mots de passe seront enregistrés en clair dans le fichier de connexions.",
"testConnection": "Tester la connexion",
"save": "Enregistrer",
"failSave": "Échec de l’enregistrement de la connexion",
Expand Down
Loading