Skip to content

Fix #957: persist SSH known-host trust across app restarts#960

Merged
binaricat merged 1 commit into
mainfrom
fix/957-known-hosts-hydration-race
May 12, 2026
Merged

Fix #957: persist SSH known-host trust across app restarts#960
binaricat merged 1 commit into
mainfrom
fix/957-known-hosts-hydration-race

Conversation

@binaricat
Copy link
Copy Markdown
Owner

@binaricat binaricat commented May 12, 2026

Summary

Fixes the "remembered host fingerprints stop working after restarting netcatty" symptom reported in #957.

Root cause

Commit bce33f34 "Fix SSH known host verification" (shipped in 1.1.3) introduced a hostVerifier in the main process that decides whether a host is trusted by reading the options.knownHosts array passed in from the renderer.

The renderer's knownHosts comes from the React state owned by useVaultState, and that state is hydrated asynchronously: the init flow in useVaultState.ts:404 awaits the decryption of hosts / keys / identities / proxyProfiles before it finally reads knownHosts at line 509. For users with several encrypted keys, the full hydration can easily take a few hundred milliseconds.

Until that finishes the React state is still [], and App.tsx lines 1999 / 2072 pass that state straight through to VaultView / TerminalLayerMount, which eventually reaches sshBridge.cjs:762 createHostVerifier({ knownHosts: options.knownHosts }). If the user clicks connect before hydration completes (manually, or via auto-restored sessions), the verifier sees an empty array, classifies every host as "unknown", and prompts. The user's "accept" is correctly written to localStorage, but next restart the same race fires again — giving the impression that the fingerprint was never saved.

Fix

The project already had a getEffectiveKnownHosts helper. Its docstring spells out the exact situation:

If the hook/state knownHosts is empty but localStorage has data, read from localStorage so local backups do not miss entries while async store initialization is still settling.

The sync-payload code path already used it (App.tsx:479), but the SSH-connect path was missed. This PR wires the same helper into the knownHosts prop that VaultView and TerminalLayerMount receive.

Wrapped in useMemo keyed on the knownHosts state so that:

  • once hydration completes, the memo returns the state itself (stable reference, downstream React.memo equality checks keep working)
  • before hydration, the memo returns the localStorage fallback with a stable reference within the same render cycle

Test plan

  • npx tsc --noEmit — no new errors in changed files (other pre-existing repo-wide TS errors are unrelated to this PR)
  • Ran the hostKey / knownHosts-related test suites — 51 tests pass:
    • electron/bridges/hostKeyVerifier.test.cjs
    • components/terminal/hostKeyVerification.test.ts
    • components/terminal/runtime/createTerminalSessionStarters.test.ts
    • components/TerminalLayer.memo.test.tsx
    • domain/knownHosts.test.ts
  • Manual regression: on a machine where the fingerprint was already accepted, restart netcatty and reconnect — the confirmation dialog should no longer appear
  • Manual regression: first-time connections to unknown hosts still prompt as expected; after accepting, restart immediately and reconnect — the dialog should not reappear

Related

🤖 Generated with Claude Code

useVaultState hydrates knownHosts asynchronously — its init awaits the
decryption of hosts, keys, identities and proxyProfiles before reading
knownHosts from localStorage. The state is briefly [] at boot even when
localStorage has saved entries.

The host-key verifier introduced in bce33f3 reads the renderer's
knownHosts state at connect time. Any SSH connect that fires inside
that hydration window (manual click or auto-restored session) sees an
empty trust list, marks every host as unknown, and prompts again. The
fix accepted by the user is saved to localStorage, but next restart
the same race repeats, giving the impression that fingerprints are
never persisted.

Use the existing getEffectiveKnownHosts helper at the two sites that
feed the SSH connect path (VaultView + TerminalLayerMount). The helper
falls back to localStorage while state is still settling, mirroring
the same pattern already applied to sync payloads (App.tsx:479).

Memoised on the knownHosts state so the prop reference is stable and
the TerminalLayer/VaultView React.memo equality checks still hold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@binaricat binaricat merged commit ffd3111 into main May 12, 2026
16 checks passed
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.

1 participant