Skip to content

fix(connections): keep secrets in the keychain by default and write config files 0600 - #980

Merged
debba merged 4 commits into
TabularisDB:mainfrom
cestercian:fix/connection-secrets-private-by-default
Oct 12, 2026
Merged

debba merged 4 commits into
TabularisDB:mainfrom
cestercian:fix/connection-secrets-private-by-default

Conversation

@cestercian

Copy link
Copy Markdown
Contributor

Fixes #971

New database connections now default to storing the password in the OS keychain, same as the SSH manager already does. Editing an existing connection keeps whatever save_in_keychain value it had.

Config files that can hold secrets (connections.json, the SSH and K8s connection files) go through a small write_private_file helper: mode 0600 on Unix, and an existing file with looser permissions gets tightened on the next save. Other platforms write as before.

If the keychain checkbox is off, the new-connection form and the SSH manager show: "Passwords will be stored in plain text in the connection file." Same string is in all 11 locales. If a database save asked for the keychain and the OS refused the write, the connection still saves, the password stays in the config file, save_in_keychain is stored as false, and that sentence shows as a warning. A connection URI is still refused unless the keychain flag is on.

Left as normal writes: keybindings, preferences, query history, notebooks, logs, theme/plugin installer files, and similar non-secret paths. Nearby open PRs #934 and #779 touch keychain error reporting / SSH secret handling; this change does not copy from them and keeps the diff focused on the default + 0600 writer.

Tests:

  • Rust: Unix helper (new file 0600, existing 0644 -> 0600) plus related persistence coverage
  • Frontend: NewConnectionModal / SSH manager cases for the new default and plain-text warning
  • cargo +stable test --locked --lib — 1581 passed
  • pnpm lint / pnpm typecheck — pass
  • pnpm exec vitest run on the new modal + SSH manager tests — 51 passed

Comment thread src-tauri/src/commands.rs
@@ -1548,8 +1612,12 @@ pub async fn update_connection<R: Runtime>(
}
}

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.

WARNING: Orphaned keychain entries if URI write fails after password fallback

In update_connection, password keychain operations run before persist_connection_uri_change (line 1614). If URI write fails (keychain unavailable), the entire save fails but any password keychain entries that succeeded are left orphaned — the connection file is never saved. Consider running URI persistence first, or rolling back password keychain writes on URI failure.

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.

good catch, fixed in 946c76a. save and update now track which keychain entries they wrote (plus the old value on update) and put them back if the config/URI write fails, so a failed save doesn't leave anything behind

use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

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

@@ -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

@kilo-code-bot

kilo-code-bot Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

This review did not finish. The model reached its output limit before it
could write the review — a reasoning model can spend the whole budget thinking.
Re-run the review, or lower the model's thinking effort, and it should get
further. Any inline comments below are from an earlier review.

Previous Review Summaries (2 snapshots, latest commit 946c76a)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 946c76a)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/commands.rs 2184 Orphaned keychain entries if SSH config write fails after keychain writes
src-tauri/src/commands.rs 2253 Orphaned keychain entries if SSH config write fails after keychain writes
src-tauri/src/commands.rs 6975 Orphaned keychain entries if SSH config write fails after keychain writes

SUGGESTION

File Line Issue
src-tauri/src/commands.rs 2285 delete_ssh_connection deletes keychain entries before file write
Files Reviewed (1 file)
  • src-tauri/src/commands.rs - 4 issues

Fix these issues in Kilo Cloud

Previous review (commit 5c99af5)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/commands.rs 1335 (save_connection) / 1614 (update_connection) Orphaned keychain entries if URI write fails after password fallback. Password keychain operations run before persist_connection_uri_change; if URI write fails, the save fails but any password keychain entries that succeeded are left orphaned (connection file never saved). Consider running URI persistence first, or rolling back password keychain writes on URI failure.
src-tauri/src/commands.rs 2082, 2151, 2183, 2359, 2399, 2420, 6873, 6886 SSH/K8s connection functions (save_ssh_connection, update_ssh_connection, delete_ssh_connection, save_k8s_connection, update_k8s_connection, delete_k8s_connection, apply_export_payload) now use write_private_file for 0600 permissions but lack the keychain-unavailable fallback pattern added to save_connection/update_connection. If keychain is unavailable, these operations fail entirely instead of falling back to plain-text storage with a warning. (Lines not in diff hunks; noted for consistency.)

SUGGESTION

File Line Issue
src-tauri/src/persistence.rs 87 Redundant permission setting in write_private_file: sets permissions 3 times on Unix (existing file, open mode, after write). The mode(0o600) at open already ensures correct permissions for new files; only the first set_permissions for existing files is needed.
src/components/modals/NewConnectionModal.tsx 373 Implicit undefined → false coercion for existing connection's save_in_keychain. initialConnection.params.save_in_keychain can be undefined; the checkbox uses !!formData.save_in_keychain. Suggest explicit nullish coalescing: save_in_keychain: initialConnection ? initialConnection.params.save_in_keychain ?? false : true,
Files Reviewed (17 files)
  • src-tauri/src/commands.rs - 2 issues
  • src-tauri/src/persistence.rs - 1 issue
  • src-tauri/src/persistence_tests.rs - No issues
  • src/components/modals/NewConnectionModal.tsx - 1 issue
  • src/components/ssh/SshConnectionsManager.tsx - No issues
  • src/i18n/locales/de.json - No issues
  • src/i18n/locales/en.json - No issues
  • src/i18n/locales/es.json - No issues
  • src/i18n/locales/fr.json - No issues
  • src/i18n/locales/it.json - No issues
  • src/i18n/locales/ja.json - No issues
  • src/i18n/locales/ko.json - No issues
  • src/i18n/locales/pt-BR.json - No issues
  • src/i18n/locales/ru.json - No issues
  • src/i18n/locales/tl.json - No issues
  • src/i18n/locales/zh.json - No issues
  • tests/components/modals/NewConnectionModal.test.tsx - No issues

Fix these issues in Kilo Cloud

@cursor
cursor Bot force-pushed the fix/connection-secrets-private-by-default branch from 5c99af5 to 946c76a Compare October 12, 2026 04:09
@cestercian

Copy link
Copy Markdown
Contributor Author

on the SSH/K8s fallback point, I left those alone on purpose. this PR only swaps their fs::write for write_private_file, the keychain handling there is the same as main (SSH already defaulted to keychain before this) and K8s connections don't keep anything in the keychain. a plaintext fallback there feels like its own change, happy to do a follow up if you want it

Comment thread src-tauri/src/commands.rs
ssh_connections.push(ssh_to_save.clone());
let json = serde_json::to_string_pretty(&ssh_connections).map_err(|e| e.to_string())?;
fs::write(path, json).map_err(|e| e.to_string())?;
persistence::write_private_file(&path, json)?;

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.

WARNING: Orphaned keychain entries if SSH config write fails after keychain writes

save_ssh_connection stores SSH password/passphrase in the keychain (lines ~2155-2177) before writing the SSH config file with write_private_file (this line). If the file write fails (disk full, permissions, etc.), the keychain entries are left orphaned — the SSH connection is never saved but secrets remain in the keychain. Apply the same KeychainSecretWrites rollback pattern used in save_connection/update_connection.

Comment thread src-tauri/src/commands.rs

let json = serde_json::to_string_pretty(&ssh_connections).map_err(|e| e.to_string())?;
fs::write(path, json).map_err(|e| e.to_string())?;
persistence::write_private_file(&path, json)?;

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.

WARNING: Orphaned keychain entries if SSH config write fails after keychain writes

update_ssh_connection stores SSH password/passphrase in the keychain (lines ~2210-2220) before writing the SSH config file with write_private_file (this line). If the file write fails, the keychain entries are left orphaned. Apply the same KeychainSecretWrites rollback pattern used in save_connection/update_connection.

Comment thread src-tauri/src/commands.rs
save_connections_and_invalidate(&app, &conn_path, &current_file)?;
let ssh_json = serde_json::to_string_pretty(&current_ssh).map_err(|e| e.to_string())?;
fs::write(ssh_path, ssh_json).map_err(|e| e.to_string())?;
persistence::write_private_file(&ssh_path, ssh_json)?;

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.

WARNING: Orphaned keychain entries if SSH config write fails after keychain writes

apply_export_payload writes multiple keychain entries (DB passwords, connection URIs, SSH passwords, SSH passphrases — lines ~6903-6960) before saving the SSH config file with write_private_file (this line). If the file write fails, all those keychain entries are left orphaned. This affects bulk import where many secrets could be stranded. Consider tracking keychain writes per connection/SSH record and rolling back on failure, or persisting files first then moving secrets to keychain.

Comment thread src-tauri/src/commands.rs

let json = serde_json::to_string_pretty(&ssh_connections).map_err(|e| e.to_string())?;
fs::write(path, json).map_err(|e| e.to_string())?;
persistence::write_private_file(&path, json)?;

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: delete_ssh_connection deletes keychain entries before file write

Keychain entries are deleted (lines ~2278-2282) before the SSH config file is rewritten with write_private_file (this line). If the file write fails, the keychain secrets are already gone but the SSH connection record remains in the file — the user loses the secrets but keeps the (now unusable) connection entry. Consider deleting keychain entries only after the file write succeeds, or use a rollback pattern.

debba
debba previously approved these changes Oct 12, 2026

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @cestercian, really solid work on this one. I like that KeychainSecretWrites captures the previous keychain value before each write, so a failed config write puts back exactly what was there instead of blindly deleting. The two Unix tests for write_private_file (fresh file and tightening an existing 0644) are exactly what I wanted to see. Your reasoning on the SSH/K8s fallback makes sense to me too: keeping that out of this PR is the right call, and we can look at it separately if it turns out to matter.

I ran the modal and SSH manager tests (51 passing), the persistence tests (7 passing), the full Rust lib suite (the only failures are ones I also get on main on this machine), lint and typecheck. I'm approving. There are two small follow-ups:

  1. src/components/ssh/SshConnectionsManager.tsx:570: the description mentions SSH manager test cases, but tests/components/ssh/SshConnectionsManager.test.tsx isn't touched, so the new plain-text warning there has no coverage. One test that unticks the box and checks the message (and the aria-describedby) would match what the PR says.
  2. src-tauri/src/commands.rs:1369 and 1390 (same pattern at 1587/1609 in update_connection): if the DB password lands in the keychain but the SSH password or passphrase write then fails, we fall back to the file and clear save_in_keychain. The DB password we just stored stays in the keychain as a stale copy, though. It gets cleaned on delete or on the next non-keychain save, so nothing breaks, but calling keychain_writes.roll_back in the fallback branch would keep the keychain and the file consistent.

I'll still do my manual checks in the app before merging.

@cestercian

Copy link
Copy Markdown
Contributor Author

Pushed the follow-ups. There's a new SshConnectionsManager test that unticks the keychain box and checks the plain-text warning plus its aria-describedby. For the fallback case I went a little differently from a straight roll_back. Once the config file is saved with the secrets inline, any keychain entries this save already wrote get deleted, since the config doesn't point at them anymore. Restoring the old values on update would just leave orphans. If the config write itself fails, roll_back still runs as before. Happy to switch to plain roll_back if you'd rather have that.

@debba debba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @cestercian, both follow-ups look good. The new SshConnectionsManager test covers exactly what was missing: it checks the warning isn't there while the box is ticked, then that it shows up with the right id and is wired through aria-describedby once you untick it.

On the fallback, you're right, and discard is better than what I suggested. Once the config file lands with the secrets inline and save_in_keychain set to false, nothing reads those keychain entries anymore, so restoring the previous values on update would only leave orphans behind. Deleting what this save wrote, and keeping roll_back for the case where the config write itself fails, keeps the two paths easy to follow.

On 34a4bca I ran the modal and SSH manager tests (52 passing), the persistence tests, lint, typecheck and clippy, all clean. The Rust lib suite only fails on environment-related tests on my machine that have nothing to do with this PR.

Approving. I'll merge as soon as CI is green.

@github-actions

Copy link
Copy Markdown
Contributor

Preview build

The preview build of commit 34a4bca failed (skipped). See the workflow run for logs.

@debba
debba merged commit e1ef75f into TabularisDB:main Oct 12, 2026
8 of 9 checks passed
aesslinger added a commit to aesslinger/tabularis that referenced this pull request Oct 13, 2026
…ease)

Resolves 21 conflict files across 38 upstream commits (v0.28.0 release):

K8s tunnel health-check (TabularisDB#942):
- commands.rs: upstream's reuse_or_discard_dead replaces our simple tunnel
  check; k8s connection files now use persistence::write_private_file (TabularisDB#980)

Plugin RPC bounded shutdown (TabularisDB#961) + database routing (TabularisDB#960):
- plugins/driver.rs: upstream's dropped_plugin_response_error,
  route_selected_plugin_database, accepting_calls, manager_task integrated
  with our with_primary_database (now preserves Multiple arrays for
  schema-less plugins so route_selected_plugin_database can route them)
- Our is_method_not_found (PluginCallError-typed) kept over upstream's
  string-typed version; upstream's new helpers added alongside

Window placement (TabularisDB#953) + e2e mock:
- lib.rs: both our e2e_dialog_mock and upstream's window_placement module

View definition context menu (TabularisDB#1010):
- ExplorerSidebar.tsx: upstream's createViewDefinitionContextItems helper
  adopted; extended with our database param for multi-db support

Editor MRU tab switching (TabularisDB#1011):
- EditorContext.ts/EditorProvider.tsx: upstream's setActiveTabId preview
  option + getRecentTabIds merged with our database param on getSchema
  and findExistingTableTab

NewConnectionModal keychain default (TabularisDB#980):
- NewConnectionModal.tsx: our isSchemaBasedMultiDbCapable +
  hasOptedIntoDatabaseSelection + upstream's withoutBlankSslMode import
- NewConnectionModal.test.tsx: both our multi-database opt-in tests and
  upstream's keychain default tests preserved

Theme test tempdir canonicalization:
- 14 theme_packages test files: our temp_dir() kept (functionally identical
  to upstream's super::tempdir()); both solve the macOS /var -> /private/var
  symlink issue

Struct field additions (TabularisDB#840/TabularisDB#939 numeric precision, TabularisDB#961 shutdown):
- dump_commands.rs: numeric_precision/numeric_scale added to TableColumn
  test initializer
- plugins/driver.rs test helpers: route_selected_database,
  accepting_calls, manager_task added to PluginProcess test initializers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Connection secrets stored in plain text by default, config files world-readable

2 participants