Close open sessions on vault reset, and stop retaining imported key material after Cancel, a failed import or a vault lock - #58
Conversation
…terial secure_reset_vault destroyed the vault, the biometric credential and the auth generation, but left state.sessions, state.sftp and state.local_terminals untouched. Every SSH connection, SFTP channel and local PTY authenticated before the reset stayed live and writable through session_write and the sftp_* commands, while the reset dialog told the user the reset "permanently deletes all stored SSH keys and credentials". Tear those down after the destructive boundary. SessionManager::close_all and SftpManager::close_all drain their maps under the lock and release the guard before any network work, so a session stops being addressable the moment the call starts; a remote that never acknowledges the disconnect can only delay the goodbye. The teardown runs under a budget and its outcome is discarded, so a connection that will not close cannot turn a completed reset into a reported failure, and normalize_reset_result / reset_crossed_destructive_boundary keep their existing semantics. KeyManager cleared its add-key form only on success, so a private key pasted or loaded through Browse survived Cancel, a failed import and a vault auto-lock, and was rendered back into the plaintext textarea the next time the dialog opened. Clear on every exit: the failure branch, both cancel paths, a watcher on the dialog's visibility, and unmount.
Greptile SummaryThis update strengthens vault-reset containment and clears retained imported key material. It drains SSH and SFTP session reachability before remote teardown, clears local terminals during reset, bounds best-effort cleanup, and resets imported private-key state when the dialog closes, an import fails, the vault locks, or the component unmounts. Confidence Score: 5/5Safe to merge with no blocking issues identified. The previous non-blocking test-coverage concern remains outstanding: the containment test creates empty SSH and SFTP managers, so it does not exercise draining populated remote-session entries. This does not block merging. Reviews (4): Last reviewed commit: "Reset the Add Key form on vault lock onl..." | Re-trigger Greptile |
| let sessions = session_manager(); | ||
| let sftp = SftpManager::new(); | ||
| let locals = Mutex::new(HashMap::from([("local-1".to_string(), ())])); | ||
|
|
||
| assert!(contain_open_sessions(&sessions, &sftp, &locals, TEST_BUDGET).await); | ||
|
|
||
| assert!(sessions.list().await.is_empty()); | ||
| assert!(sftp.list().await.is_empty()); | ||
| assert!(locals.lock().await.is_empty()); |
There was a problem hiding this comment.
This containment test creates new SSH and SFTP managers immediately before reset, so both are already empty. Its assertions therefore pass without executing the new non-empty drain and disconnect paths in close_all. Add focused manager tests with controllably populated entries, or a test seam that lets this test populate them, so a teardown regression is caught before release.
This is non-blocking, but the current test adds coverage confidence without covering the behavior it is intended to protect.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Artifacts
Focused empty-manager reproduction script
- Authored script creates a temporary integration test, runs the real public close_all APIs with fresh managers, prints their entry counts and return values, and removes the temporary test; it directly checks that fresh managers exercise zero-item paths.
Pre-PR containment test absence capture
- Executed command against the parent commit finds no reset containment test, establishing the before side of the PR test addition.
Fresh SSH and SFTP manager close_all execution
- Executed authored integration test passes while printing zero SSH and SFTP entries and zero close_all return values; the added test setup cannot execute non-empty manager teardown.
Targeted desktop containment test build attempt
- Attempted execution of the exact vault containment test stops during Tauri dependencies because gdk-3.0 is unavailable; the focused core reproduction still executed the changed manager APIs.
…port's form The earlier commit's claim that an auto-lock was covered was wrong. Nothing in KeyManager reacted to one. `useAutoLock` calls `vault.lock()` and nothing else: it never opens `VaultUnlockModal`, which is driven by `ui.showVaultUnlockModal` from the SSH flows, so `showAdd` does not change and the visibility watcher does not fire. `VaultUnlockModal` is also a sibling of `<main>` rather than a replacement for the view, so `KeyManager` stays mounted and `onUnmounted` does not fire either. After a lock the private key was still in `addForm`, still rendered in the plaintext textarea, and the dialog was still open — the one component holding raw private key material was the one missing the guard that `VaultView` and `VaultUnlockModal` already have. Watch `vault.unlocked` with the same unguarded `flush: "sync"` shape those two use, and close the dialog with it so nothing survives the session boundary in the form or in the DOM. A failed import no longer tears down the whole dialog. The common rejection is a wrong passphrase on an encrypted key, and `clearAddForm` also resets `import`, so the old `resetForm()` closed the dialog, dropped the label and would have reopened on the Generate tab. Split the secret fields out as `clearKeyMaterial` and call only that: the key and passphrase go, the label and the Import tab stay.
Review: APPROVE WITH NITSFocused on the auto-lock watcher and the split-out What I verified
No loop, and the double-clear is by construction. Clearing in both directions does not break anything reachable. The failed-import behaviour is what the body says. The corrected body claims check out. No key handler in Numbers reproduce, on a checkout I confirmed was at Nits
No AI attribution anywhere in the diff, comments or commit messages. |
The watcher fired in both directions, and this view is reachable while the vault is locked. An unlock landing on a half-typed import closed the dialog and dropped the label, costing the user their context for no security gain: the only way key material survives to that transition is if the person who typed it is the person who just authenticated. A synchronous flush still sees every transition through locked, so a lock and an unlock in one tick clears on the lock.
|
APPROVE WITH NITS — reviewed at Verified locally in a clean detached checkout (
The guard is correctI went looking for a
I also checked the Non-vacuityBoth directions check out. The new test fails at the parent The lock-direction containment test is still load-bearing. I neutered the watcher body to Nit — the stated justification is stronger than what holds
The conclusion is still right, for a different reason. A locked vault does not hide this view. The unlock modal is only ever opened by I would reword to the weaker claim, roughly: material present at a Worth fixing because it is the whole security argument for the change, and the next person to touch this watcher will read that comment as the spec. MinorThe same-tick lock-then-unlock behaviour is load-bearing enough to deserve a test of its own — it is the sentence in the comment doing the most work, and it currently rests on a Vue scheduling guarantee with nothing pinning it. My probe above is three lines on top of the existing Not blocking. The narrowing itself is right and the containment it preserves is properly tested. |
|
@greptile-apps, can you review this PR and report the found issues? |
|
@computerbox124 Ready. Greptile 5/5. Two distinct gaps closed, and the second one is the reason this PR went through three rounds. Vault reset left every session alive. One trap avoided: the existing
Fixed with Verified live through the real auto-lock path: Also declined, deliberately: masking the textarea. A 314 vitest / 68 e2e pass; every new test verified to fail against the unfixed component. |
|
For the merge queue: Nothing to change here. @computerbox124 всё готово, можно смотреть — 5/5, merges cleanly into |
Closes #56. Both findings originally came from #44 (items 1 and 3), which can have them checked off when this lands.
Same defect in two places: a security control that does less than the UI says it does.
1. A reset now drops the access the destroyed keys bought
secure_reset_vaultdestroyed the vault, the biometric credential and the auth generation, and leftstate.sessions,state.sftpandstate.local_terminalsalone. Every SSH session, SFTP channel and local PTY opened before the reset stayed live and writable throughsession_writeand thesftp_*commands, while the dialog told the user the reset "permanently deletes all stored SSH keys and credentials". The keys were gone; the connections they authenticated were not.I went with closing the sessions rather than softening the copy. The reset is the only "make it stop" gesture in the app, it is already built like a containment control everywhere else, and a reset that leaves a writable shell open on the machine the user was worried about is worse than no reset — they now believe they are done. Re-establishing the sessions is not a real cost, since the keys that opened them no longer exist.
The care is all in not letting that break the reset itself:
SessionManager::close_all/SftpManager::close_alldrain their map under the lock and release the guard before any network work. A session stops being addressable the instant the call starts, so a remote that never acknowledges the disconnect can only delay the polite goodbye — it can never keep a session reachable. The existingcloseholds the map lock acrosshandle.disconnect().await, which is exactly the shape that would have deadlocked here.reset_crossed_destructive_boundaryand afterauth_generation.invalidate()/ the passphrase clear.normalize_reset_resultandreset_crossed_destructive_boundaryare untouched and keep their semantics, including the[vault-reset-durability]marker the frontend reconciles against.contain_open_sessionsreturns whether it finished;secure_reset_vaultignores that and returns thereset_resultit already computed. A connection that will not close cannot turn a completed reset into a reported failure, and cannot leave the command hanging after the vault file is already unlinked — which would strand the renderer showing an unlocked vault that no longer exists.The reset dialog now also says open sessions and terminals are closed, so the warning covers the consequence the user is consenting to.
session_write,session_resizeandclose_sessionare unchanged; the panes reconcile through the existingsession-closedevent, which the reader thread and the SSH channel loop already emit when their transport goes away.2.
KeyManagerstops retaining imported key materialresetForm()ran on success only. A private key pasted — or pulled off disk by Browse, which loads the whole file — survived Cancel, the dialog's own dismissal, a failed import and a vault lock, and was rendered straight back into the plaintext<Textarea>the next time the dialog opened.Correction to an earlier revision of this description and of the first commit message. Both said the auto-lock path was covered by the
showAddwatcher andonUnmounted. It was not, and the enumeration should not have implied it. Neither of those fires on an auto-lock:useAutoLockcallsvault.lock()and nothing else. It never opensVaultUnlockModal— that is driven byui.showVaultUnlockModal, which only the SSH flows set — soshowAdddoes not change and the visibility watcher does not run.VaultUnlockModalis mounted inApp.vueas a sibling of</main>, not as a replacement for the view inside it, soKeyManagerstays mounted andonUnmounteddoes not run either.Against the previous head of this branch, after
vault.unlockedflipped false the key was still inaddForm, still rendered in the plaintext textarea, and the dialog was still open. The one component that holds raw private key material was the only one missing the guardVaultView.vueandVaultUnlockModal.vuealready have.watch(() => vault.unlocked, resetForm, { flush: "sync" })now closes that gap, with the same unguardedflush: "sync"shape as those two call sites — it clears in both directions, so a lock and an unlock in the same tick cannot carry key material across an authentication boundary.So the exits that clear it are: the Cancel button, the dialog's
@close(which the overlay click emits), theflush: "sync"watcher onshowAdd, theflush: "sync"watcher onvault.unlocked, andonUnmounted. The redundancy is deliberate: a sixth exit path added later cannot quietly reopen the window.There is no Escape path, and I did not add one. An earlier revision of this description listed "overlay/Escape dismissal" as one exit. That was wrong: neither
ui/Dialog.vuenorui/Overlay.vuehas a key handler, andApp.vue's globalkeydownonly handles Escape for fullscreen panes and the command palette — so Escape does nothing to this dialog at all. Every dialog that closes on Escape today (SftpBrowser.vue,VaultUnlockModal.vue) binds@keydown.escapeon its own focused input. Adding Escape toui/Dialog.vuewould change dismissal behaviour for every dialog in the app, which is a UX change that does not belong in a key-retention fix; binding it to this dialog's inputs only would work solely while focus is in a field, which is not worth claiming as a security exit. Corrected the enumeration instead — the retention window is closed by the watchers regardless of how the dialog goes away.A failed import no longer tears down the whole dialog. The failure branch used to call
resetForm(), andclearAddFormalso resetsimport, so a rejected key closed the dialog, dropped the label, and would have reopened on the Generate tab. The common rejection is a wrong passphrase on an encrypted key — the label and the tab selection were never the problem, and making the user rebuild them is punishment for a typo. The secret fields are split out asclearKeyMaterial()and the failure branch calls only that:privateKeyandpassphrasego, the dialog stays open on the Import tab with the label intact. Clearing the secret is still unconditional.I did not mask the textarea. A
<textarea>has notype="password", so masking means-webkit-text-securityor a fake overlay, which breaks the one thing users need that field for — checking that they pasted the right key. And it fixes the wrong half: the material being visible while the user is deliberately looking at it is fine. The defect is that it is still there afterwards.Tests
New:
core+desktop/src-tauri: teardown drops every session, SFTP channel and local terminal; a teardown step that never completes still returns to the reset; an unfinished teardown leaves the reset outcome (including the durability marker) untouched.desktop/src/components/KeyManager.test.ts: key material is gone after Cancel, after an overlay dismissal, after a rejected import (which also asserts the dialog, the Import tab and the label survive), after a vault lock (which also asserts the dialog closes and the key leaves the DOM), and after unmount.Non-vacuity, checked by running the suite against the component with the fix reverted:
Also driven through the live renderer: opened the import dialog with a key pasted in, called
vault.lock()— which is all an auto-lock does — and the dialog closes with the key absent from the DOM. Cancel/reopen leaves both fields empty. (Dev server on127.0.0.1:1437; ports near 1420 were held by other checkouts, and Vite bindslocalhostto::1only on this machine, so the servedKeyManager.vuewas fetched back from the dev server to confirm it was this branch being exercised.)Expected conflicts
VaultSessionSlot, so thelet mut pw = state.passphrase.lock().await; *pw = None;that my teardown call sits directly after becomesstate.vault_session.clear().await. The conflict is that one line; the teardown call moves below whichever version wins. It also converts severalvault_commands.rstests to#[tokio::test]and reworks theuseblock I added imports to. Happy to rebase onto it — say the word and I'll do it in whichever order suits.core/src/session.rs, but only the imports at the top and the body ofcreate_ssh_session's spawned loop.close_allgoes in afterlist()at the end of the impl, so this should merge cleanly.App.vue,stores/**,TerminalPane.vueor anyCargo.toml.KeyManager.vuenow importsstores/vault, but does not modify it.