(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");
+ }
}
diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx
index 638a34dbd..92632ca4b 100644
--- a/src/components/modals/NewConnectionModal.tsx
+++ b/src/components/modals/NewConnectionModal.tsx
@@ -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";
@@ -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();
@@ -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
+ ? (initialConnection.params.save_in_keychain ?? false)
+ : true,
});
const [selectedDatabasesState, setSelectedDatabasesState] = useState<
string[]
@@ -1934,6 +1941,7 @@ export const NewConnectionModal = ({
ssh_port: 22,
k8s_enabled: false,
ssm_enabled: false,
+ save_in_keychain: true,
});
setSelectedDatabasesState([]);
setLoadAllDatabases(true);
@@ -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,
@@ -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
@@ -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", {
@@ -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;
@@ -2905,19 +2927,34 @@ export const NewConnectionModal = ({
)}
{/* Keychain */}
-
- {
- updateField("save_in_keychain", e.target.checked);
- }}
- className="accent-accent-primary w-3.5 h-3.5 rounded"
- />
-
- {t("newConnection.saveKeychain")}
-
-
+
+
+ {
+ 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"
+ />
+
+ {t("newConnection.saveKeychain")}
+
+
+ {!formData.save_in_keychain && (
+
+ {t("newConnection.secretsStoredInPlainText")}
+
+ )}
+
>
)}
diff --git a/src/components/ssh/SshConnectionsManager.tsx b/src/components/ssh/SshConnectionsManager.tsx
index f4a72ce0a..2b9d17052 100644
--- a/src/components/ssh/SshConnectionsManager.tsx
+++ b/src/components/ssh/SshConnectionsManager.tsx
@@ -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);
@@ -562,6 +567,14 @@ export function SshConnectionsManager({
{t("newConnection.saveKeychain")}
+ {!formData.save_in_keychain && (
+
+ {t("newConnection.secretsStoredInPlainText")}
+
+ )}
({
unlisten: vi.fn(),
}));
+const alertMocks = vi.hoisted(() => ({
+ showAlert: vi.fn(),
+}));
+
vi.mock("@tauri-apps/api/event", () => ({
listen: eventMocks.listen,
}));
+vi.mock("../../../src/hooks/useAlert", () => ({
+ useAlert: () => ({ showAlert: alertMocks.showAlert }),
+}));
+
vi.mock("../../../src/components/ui/Modal", () => ({
Modal: ({
isOpen,
@@ -1700,3 +1708,148 @@ describe("NewConnectionModal extra_fields slot credential toggle", () => {
expect(screen.getByTestId("plugin-extra")).toHaveTextContent("{}");
});
});
+
+describe("NewConnectionModal keychain default", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ driverState.defaultPort = 3306;
+ vi.mocked(invoke).mockImplementation((command) => {
+ if (command === "save_connection") {
+ return Promise.resolve({
+ id: "new-connection",
+ params: { save_in_keychain: true },
+ });
+ }
+ return Promise.resolve(undefined);
+ });
+ sshMocks.loadSshConnections.mockResolvedValue([]);
+ k8sMocks.loadK8sConnections.mockResolvedValue([]);
+ eventMocks.listen.mockResolvedValue(eventMocks.unlisten);
+ });
+
+ function keychainCheckbox() {
+ return screen.getByRole("checkbox", { name: "newConnection.saveKeychain" });
+ }
+
+ it("defaults a new connection to the keychain and keeps it on when the driver changes", () => {
+ renderModal();
+ pickEngineFromCatalogue();
+
+ expect(keychainCheckbox()).toBeChecked();
+ expect(
+ screen.queryByText("newConnection.secretsStoredInPlainText"),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "newConnection.changeDatabase" }));
+ pickEngineFromCatalogue();
+
+ expect(keychainCheckbox()).toBeChecked();
+ expect(
+ screen.queryByText("newConnection.secretsStoredInPlainText"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("warns that secrets are stored in plain text when the keychain is turned off", () => {
+ renderModal();
+ pickEngineFromCatalogue();
+
+ fireEvent.click(keychainCheckbox());
+
+ expect(keychainCheckbox()).not.toBeChecked();
+ expect(
+ screen.getByText("newConnection.secretsStoredInPlainText"),
+ ).toBeInTheDocument();
+ });
+
+ it("saves a new connection with the keychain enabled", async () => {
+ renderModal();
+ pickEngineFromCatalogue();
+
+ fireEvent.change(screen.getByPlaceholderText("newConnection.namePlaceholder"), {
+ target: { value: "Local" },
+ });
+ fireEvent.change(screen.getByPlaceholderText("newConnection.dbNamePlaceholder"), {
+ target: { value: "shop" },
+ });
+ fireEvent.click(screen.getByText("newConnection.save"));
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ "save_connection",
+ expect.objectContaining({
+ params: expect.objectContaining({ save_in_keychain: true }),
+ }),
+ );
+ });
+ expect(alertMocks.showAlert).not.toHaveBeenCalled();
+ });
+
+ it("tells the user when a save fell back to plain text", async () => {
+ vi.mocked(invoke).mockImplementation((command) => {
+ if (command === "save_connection") {
+ return Promise.resolve({
+ id: "new-connection",
+ params: { save_in_keychain: false },
+ });
+ }
+ return Promise.resolve(undefined);
+ });
+ renderModal();
+ pickEngineFromCatalogue();
+
+ fireEvent.change(screen.getByPlaceholderText("newConnection.namePlaceholder"), {
+ target: { value: "Local" },
+ });
+ fireEvent.change(screen.getByPlaceholderText("newConnection.dbNamePlaceholder"), {
+ target: { value: "shop" },
+ });
+ fireEvent.click(screen.getByText("newConnection.save"));
+
+ await waitFor(() => {
+ expect(alertMocks.showAlert).toHaveBeenCalledWith(
+ "newConnection.secretsStoredInPlainText",
+ { kind: "warning" },
+ );
+ });
+ });
+
+ it("keeps an existing connection's stored keychain setting", async () => {
+ renderModal(
+ createInitialConnection({
+ save_in_keychain: false,
+ database: "shop",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ "get_connection_by_id",
+ expect.anything(),
+ );
+ });
+ expect(keychainCheckbox()).not.toBeChecked();
+ expect(
+ screen.getByText("newConnection.secretsStoredInPlainText"),
+ ).toBeInTheDocument();
+ });
+
+ it("keeps an existing keychain-backed connection opted in", async () => {
+ renderModal(
+ createInitialConnection({
+ save_in_keychain: true,
+ database: "shop",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ "get_connection_by_id",
+ expect.anything(),
+ );
+ });
+ expect(keychainCheckbox()).toBeChecked();
+ expect(
+ screen.queryByText("newConnection.secretsStoredInPlainText"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/components/ssh/SshConnectionsManager.test.tsx b/tests/components/ssh/SshConnectionsManager.test.tsx
index befa494fc..0beb0c38a 100644
--- a/tests/components/ssh/SshConnectionsManager.test.tsx
+++ b/tests/components/ssh/SshConnectionsManager.test.tsx
@@ -120,6 +120,27 @@ describe("SshConnectionsManager", () => {
expect(sshMocks.updateSshConnection).not.toHaveBeenCalled();
});
+ it("warns about plain-text secrets when the keychain box is unticked", async () => {
+ render( );
+ await waitFor(() => screen.getByText("Prod bastion"));
+ fireEvent.click(screen.getByText("sshConnections.createNew"));
+
+ const toggle = screen.getByLabelText("newConnection.saveKeychain");
+ expect(toggle).toBeChecked();
+ expect(toggle).not.toHaveAttribute("aria-describedby");
+ expect(
+ screen.queryByText("newConnection.secretsStoredInPlainText"),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(toggle);
+
+ expect(toggle).not.toBeChecked();
+ const warning = screen.getByText("newConnection.secretsStoredInPlainText");
+ expect(warning).toHaveAttribute("id", "ssh-secrets-plaintext-warning");
+ expect(toggle).toHaveAttribute("aria-describedby", "ssh-secrets-plaintext-warning");
+ expect(toggle).toHaveAccessibleDescription("newConnection.secretsStoredInPlainText");
+ });
+
it("asks for confirmation before deleting and deletes on confirm", async () => {
render( );
await screen.findByText("Prod bastion");