feat(security): encrypt stored credentials at rest with keychain-backed master key - #114
Conversation
|
@GOODBOY008 Friendly ping on this one — happy to adjust or rebase as needed. 🙂 One thing I noticed: since this PR was opened, |
570b52a to
982d418
Compare
|
Rebased onto the latest While rebasing, I also folded the new SSH tunnel (jump host) fields from #79 into the encryption scope: Verification: 718 frontend tests + 170 Rust tests pass; |
982d418 to
c5fbea6
Compare
|
Rebased again onto Verification: 732 frontend tests + 177 Rust tests pass; |
There was a problem hiding this comment.
🟡 Changes recommended
Critical credential leakage, inheritance, and loss paths remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds keychain-backed AES-256-GCM encryption for stored connection credentials.
Changes:
- Adds credential sealing, opening, and migration utilities.
- Prevents plaintext exports and updates credential-editing behavior.
- Adds localization, dependencies, and related tests.
File summaries
| File | Review |
|---|---|
src/locales/zh-CN.json |
Adds Chinese credential UX strings. |
src/locales/en.json |
Adds English credential UX strings. |
src/lib/credential-crypto.ts |
Adds frontend credential sealing and opening helpers. |
src/lib/connection-storage.ts |
Critical: Legacy and failed-migration plaintext can be lost; prefix-only validation can persist malformed plaintext. |
src/lib/config-export-import.ts |
Critical: Profile credentials remain plaintext in profile storage despite export sanitization. |
src/components/connection-dialog.tsx |
Critical: New connections can inherit prior credentials. Moderate: retained credentials are omitted from connection requests. |
src/App.tsx |
Critical: Later writes can destroy credentials after sealing failures. Moderate: profile storage is not migrated. |
src/__tests__/connection-storage-sftp-ftp.property.test.ts |
Updates SFTP/FTP credential-storage tests. |
src/__tests__/connection-storage-proxy.test.ts |
Tests encrypted proxy storage. |
src/__tests__/connection-storage-advanced-fields.test.ts |
Tests encrypted advanced fields. |
src/__tests__/connection-duplicate-fields.test.tsx |
Updates secret-duplication expectations. |
src/__tests__/connection-dialog-tunnel.test.tsx |
Tests retained tunnel credentials. |
src/__tests__/connection-dialog-proxy.test.tsx |
Tests proxy credential behavior. |
src/__tests__/connection-dialog-edit-roundtrip.test.tsx |
Updates asynchronous edit/save tests. |
src/__tests__/connection-dialog-advanced-save.test.tsx |
Updates asynchronous persistence tests. |
src/__tests__/config-export-import.test.ts |
Verifies exported secrets are stripped. |
src-tauri/src/lib.rs |
Registers credential commands. |
src-tauri/src/commands.rs |
Moderate: malformed nonce lengths can panic instead of returning an error. |
src-tauri/Cargo.toml |
Adds cryptography and keychain dependencies. |
src-tauri/Cargo.lock |
Locks the updated dependency graph. |
Review details
Suppressed comments (1)
src/components/connection-dialog.tsx:337
- Do not trim secrets before encryption. Passwords and key passphrases may legitimately begin or end with whitespace, and this changes the credential so subsequent authentication fails; test emptiness without modifying the original string.
const typed = (config[field] ?? '').trim();
- Files reviewed: 19/20 changed files
- Comments generated: 9
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| } catch (error) { | ||
| // Keep the legacy plaintext on failure — never destroy the only copy. | ||
| console.error(`[Credential] Migration failed for ${conn.id}; keeping plaintext:`, error); | ||
| } |
| setPreviousSecrets({ | ||
| password: editingConnection.password ?? '', | ||
| passphrase: editingConnection.passphrase ?? '', | ||
| proxyPassword: editingConnection.proxyPassword ?? '', | ||
| vncPassword: editingConnection.vncPassword ?? '', |
| // Connection profiles — strip secrets the same way. | ||
| const profiles = ConnectionProfileManager.getProfiles(); | ||
| if (profiles.length) { | ||
| bundle.data.profiles = profiles; | ||
| bundle.data.profiles = profiles.map((p) => { |
| const clone = { ...connection }; | ||
| for (const field of SECRET_FIELDS) { | ||
| const value = clone[field]; | ||
| if (typeof value === 'string' && value.length > 0 && !value.startsWith('v1:')) { |
| for (const field of SECRET_FIELDS) { | ||
| const value = clone[field]; | ||
| if (typeof value === 'string' && value.length > 0 && !value.startsWith('v1:')) { | ||
| delete clone[field]; |
| folder: session.folder?.replace(/All Sessions/g, 'All Connections') | ||
| })); | ||
| localStorage.setItem(CONNECTIONS_STORAGE_KEY, JSON.stringify(connections)); | ||
| persistConnections(connections); |
| let nonce_bytes = BASE64 | ||
| .decode(parts[1]) | ||
| .map_err(|e| format!("Corrupt nonce: {e}"))?; |
| legacyMigrationRef.current = (async () => { | ||
| const connections = ConnectionStorageManager.getConnections(); | ||
| const withPlaintext = connections.filter((c) => | ||
| SECRET_FIELDS.some((f) => isLegacyPlaintext(c[f])), |
| // Encrypt secrets for persistence (blank field = keep stored value). | ||
| // The connect request below uses the plaintext `config` as before — only | ||
| // the saved payload carries ciphertext. | ||
| let sealedSecrets: Pick<ConnectionConfig, 'password' | 'passphrase' | 'proxyPassword' | 'vncPassword' | 'tunnelPassword' | 'tunnelPassphrase'>; | ||
| try { |
…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
- Preserve legacy plaintext through schema migration until the startup seal migration encrypts it (was: stripped before it could be sealed, silently destroying passwords on upgrade) - Protect failed-seal records from strip-on-write (markSealFailed) so an unrelated persistence (updateLastConnected, folder ops) cannot destroy the only plaintext copy; lift protection once sealing succeeds - Reset the dialog's previousSecrets when opening a new connection and on close so stored secrets never leak across connections - Decrypt retained sealed secrets into the connect request when the dialog's secret fields are blank; persistence keeps the sealed form - Seal secrets as typed without trimming whitespace - Return an error for corrupt nonce lengths in credential_open instead of panicking - Seal legacy plaintext profile passwords on startup and strip secret fields from imported profile bundles - Use the 3-segment isSealed predicate for the persistence filter so a plaintext like "v1:foo" cannot bypass it Test: 753 frontend tests pass (10 new), 177 Rust tests pass, tsc/i18n clean
c5fbea6 to
1094439
Compare
|
Thanks for the thorough review — verified each finding against the code and fixed them all. Rebased onto latest Fixed (all confirmed real):
On severity for #8: Verification: 753 frontend tests (10 new) + 177 Rust tests pass; |
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:
keyringcrate, one entry for the whole app → one authorization prompt, never per-credential).v1:<b64 nonce>:<b64 ciphertext>. Plaintext only exists in memory transiently at connect time — never written tolocalStorage.persistConnections()is the single write path and strips any non-v1:plaintext secret as a defensive layer.Verification
tsc --noEmitclean; i18n key parity (en/zh-CN) holds.Fixes #100