Skip to content

feat(security): encrypt stored credentials at rest with keychain-backed master key - #114

Open
sunxiaobin89 wants to merge 2 commits into
GOODBOY008:mainfrom
sunxiaobin89:feat/encrypted-credential-storage
Open

feat(security): encrypt stored credentials at rest with keychain-backed master key#114
sunxiaobin89 wants to merge 2 commits into
GOODBOY008:mainfrom
sunxiaobin89:feat/encrypted-credential-storage

Conversation

@sunxiaobin89

Copy link
Copy Markdown
Contributor

Summary

Encrypts connection secrets (SSH password, key passphrase, proxy password, VNC password) at rest. Previously these were stored in plaintext in the app's WebView localStorage; the export feature also wrote them into shareable files.

Approach

Single app-level master key + AES-256-GCM:

  • A random 32-byte master key is generated on first use and stored in the OS keychain (Rust keyring crate, one entry for the whole app → one authorization prompt, never per-credential).
  • Secrets are persisted as v1:<b64 nonce>:<b64 ciphertext>. Plaintext only exists in memory transiently at connect time — never written to localStorage.
  • persistConnections() is the single write path and strips any non-v1: plaintext secret as a defensive layer.
  • Startup migration seals legacy plaintext already in storage (keeps the plaintext copy on failure so the only copy is never destroyed).
  • Edit dialog never echoes a stored secret: the field stays empty with a hint ("leave blank to keep the saved password"); blank = keep stored, typed = replace.
  • Export strips all secret fields (plaintext and ciphertext) from shared config bundles.

Verification

  • 636 frontend tests + 152 Rust tests pass.
  • tsc --noEmit clean; i18n key parity (en/zh-CN) holds.
  • Manual test of save / edit / connect / reconnect / migration round-tripped end to end.

Fixes #100

@sunxiaobin89

Copy link
Copy Markdown
Contributor Author

@GOODBOY008 Friendly ping on this one — happy to adjust or rebase as needed. 🙂

One thing I noticed: since this PR was opened, main gained SSH tunnel / jump-host support (#79), which introduces a new credential field (tunnel password). If the overall approach here looks acceptable, I'd suggest including that field in the encryption scope as well — I'm happy to fold it in together with the rebase onto the latest main.

@sunxiaobin89
sunxiaobin89 force-pushed the feat/encrypted-credential-storage branch from 570b52a to 982d418 Compare September 1, 2026 07:13
@sunxiaobin89

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest main (v2.9.0) — the conflicts with the new quit-guard commands and the SSH tunnel tab are resolved.

While rebasing, I also folded the new SSH tunnel (jump host) fields from #79 into the encryption scope: tunnelPassword and tunnelPassphrase are now sealed like the other secrets, never echoed back in the edit dialog, and stripped from exported configs. Any legacy plaintext tunnel credentials migrate on startup together with the other secret fields.

Verification: 718 frontend tests + 170 Rust tests pass; tsc --noEmit, cargo clippy and i18n key parity are clean.

…ed master key

Store connection secrets (password, passphrase, proxyPassword, vncPassword)
encrypted in localStorage using AES-256-GCM with a single app-level master key
kept in the OS keychain, instead of plaintext.

- Rust: credential_seal/credential_open commands + master_key() that reads
  (or first-use creates) a random 32-byte key from the OS keychain via the
  keyring crate. Sealed format: v1:<b64 nonce>:<b64 ciphertext>.
- Frontend: credential-crypto.ts (SECRET_FIELDS, seal/open, legacy migration);
  connection-storage.persistConnections() is the single write path and strips
  any non-v1: plaintext secret as a defensive layer.
- Startup migration seals legacy plaintext (keeps plaintext on failure).
- Edit dialog never echoes a stored secret; blank keeps stored, typed replaces.
- Export strips all secret fields from shared config bundles.
- Tests updated for sealed v1: values; 636 frontend + 152 Rust pass.

Fixes GOODBOY008#100
- Add tunnelPassword and tunnelPassphrase to SECRET_FIELDS so the jump
  host credentials get the same AES-256-GCM treatment as other secrets
- Seal tunnel secrets in every dialog persist path; blank fields keep
  the stored value
- Never echo stored tunnel secrets back into the edit form (same hint
  as the password field)
- Restore blank tunnel secrets from storage (decrypted) when a saved
  connection is used to connect

Test: 718 frontend tests + 170 Rust tests pass; tsc and i18n parity clean
@sunxiaobin89
sunxiaobin89 force-pushed the feat/encrypted-credential-storage branch from 982d418 to c5fbea6 Compare September 3, 2026 05:52
@sunxiaobin89

Copy link
Copy Markdown
Contributor Author

Rebased again onto main to pick up #125 (passwordless hosts) and #103 (default SSH key path). The credential checks now follow the new passwordless semantics — blank passwords and empty key paths stay valid, while the stored-secret "keep on blank" behavior of this PR is unchanged. openConnectionSecrets runs before the shared connectionHasCredentials check so restore-time decisions see decrypted values.

Verification: 732 frontend tests + 177 Rust tests pass; tsc --noEmit and i18n key parity are clean.

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.

[Security] SSH passwords stored in plaintext in local WebView localStorage

1 participant