diff --git a/src/main/ssh/connection/ssh-connection-host-key-pin-race.test.ts b/src/main/ssh/connection/ssh-connection-host-key-pin-race.test.ts new file mode 100644 index 00000000000..54fac618167 --- /dev/null +++ b/src/main/ssh/connection/ssh-connection-host-key-pin-race.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from 'vitest' +import { clientInstances, ssh2Mock } from '../__tests__/ssh-connection-test-client' +import { resetSshConnectionMocks, spawnSystemSshCommandMock } from './ssh-connection-test-harness' +import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' +import { SshConnection } from './ssh-connection' + +vi.mock('ssh2', async () => + (await import('../__tests__/ssh-connection-test-client')).createSsh2Module() +) +vi.mock('../system-transport/system-ssh-binary', async () => + (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() +) +vi.mock('../system-transport/system-ssh-command', async (importOriginal) => + (await import('./ssh-connection-test-harness')).withSystemSshCommandMocks(await importOriginal()) +) +vi.mock('../ssh-control-socket', async () => + (await import('./ssh-connection-test-harness')).createControlSocketModule() +) +vi.mock('../config/ssh-g-config-resolution', async (importOriginal) => + (await import('./ssh-connection-test-harness')).withSshGConfigResolutionMocks( + await importOriginal() + ) +) + +const { installMock } = vi.hoisted(() => ({ installMock: vi.fn() })) +vi.mock('./ssh2-host-key-verification', () => ({ + installSsh2HostKeyVerification: installMock, + readHostKeyTrustSources: vi.fn() +})) + +it('keeps the current attempt pinned when a superseded attempt finishes its trust read later', async () => { + resetSshConnectionMocks() + ssh2Mock.connectBehavior = 'pending' + let finishStaleRead: (value: unknown) => void = () => {} + installMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishStaleRead = resolve + }) + ) + installMock.mockResolvedValue({ rejection: null, pinnedByOrcaStore: true }) + const conn = new SshConnection(createTarget(), createCallbacks()) + const stale = conn.connect().catch(() => {}) + await vi.waitFor(() => expect(installMock).toHaveBeenCalledTimes(1)) + const current = conn.connect() + await vi.waitFor(() => expect(clientInstances).toHaveLength(1)) + const currentClient = clientInstances[0] + + finishStaleRead({ rejection: null, pinnedByOrcaStore: true }) + await vi.waitFor(() => expect(clientInstances).toHaveLength(2)) + clientInstances[1].emit('error', new Error('stale attempt failed')) + await stale + currentClient.emit('error', new Error('Handshake failed: no matching host key format')) + + await expect(current).rejects.toThrow('no matching host key format') + expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() +}) diff --git a/src/main/ssh/connection/ssh-connection-manager-sign-in-reuse.test.ts b/src/main/ssh/connection/ssh-connection-manager-sign-in-reuse.test.ts index 7057b5015a3..46df112082e 100644 --- a/src/main/ssh/connection/ssh-connection-manager-sign-in-reuse.test.ts +++ b/src/main/ssh/connection/ssh-connection-manager-sign-in-reuse.test.ts @@ -101,8 +101,8 @@ describe('SshConnectionManager on a password host', () => { it('keeps the sign-in across a replacement that failed for a reason other than sign-in', async () => { await manager.disconnectConnection('target-1', manager.getConnection('target-1')!) - ssh2Mock.connectSequence = [new Error('Handshake failed: no matching cipher')] - await expect(manager.connect(createTarget())).rejects.toThrow('no matching cipher') + ssh2Mock.connectSequence = [new Error('Handshake failed: signature verification failed')] + await expect(manager.connect(createTarget())).rejects.toThrow('signature verification failed') ssh2Mock.connectSequence = ['ready'] await manager.connect(createTarget()) @@ -152,8 +152,11 @@ describe('SshConnectionManager on a password host', () => { it('does not hand on a typed password the host never accepted', async () => { await manager.disconnect('target-1') onCredentialRequest.mockResolvedValueOnce('typo') - ssh2Mock.connectSequence = [authFailure(), new Error('Handshake failed: no matching cipher')] - await expect(manager.connect(createTarget())).rejects.toThrow('no matching cipher') + ssh2Mock.connectSequence = [ + authFailure(), + new Error('Handshake failed: signature verification failed') + ] + await expect(manager.connect(createTarget())).rejects.toThrow('signature verification failed') expect(manager.hasCachedCredential(createTarget())).toBe(false) ssh2Mock.connectSequence = [authFailure(), 'ready'] diff --git a/src/main/ssh/connection/ssh-connection-system-transport.test.ts b/src/main/ssh/connection/ssh-connection-system-transport.test.ts index 2d47ed8e459..de4f07e039b 100644 --- a/src/main/ssh/connection/ssh-connection-system-transport.test.ts +++ b/src/main/ssh/connection/ssh-connection-system-transport.test.ts @@ -23,6 +23,7 @@ import { shouldUseSystemSshTransport } from '../ssh-transport-selection' import { resolveWithSshG } from '../config/ssh-g-config-resolution' import { writeFileViaSystemSsh } from '../system-transport/system-ssh-file-transfer' import { CONNECT_TIMEOUT_MS } from './ssh-connection-timing' +import { initSshHostKeyStoreFile, trustHostKey } from '../ssh-host-key-store' import type { SshTarget } from '../../../shared/ssh-types' import { createOpenSshPrivateKeyFixture, @@ -332,6 +333,69 @@ describe('SshConnection', () => { ) expect(conn.usesSystemSshTransport()).toBe(false) }) + + it.each([ + 'Handshake failed: no matching host key format', + 'Handshake failed: no matching key exchange algorithm', + 'Handshake failed: no matching C->S cipher' + ])( + 'falls back to system SSH when ssh2 shares no algorithm with the host (%s)', + async (message) => { + ssh2Mock.connectBehavior = 'error' + ssh2Mock.connectErrorMessage = message + const conn = new SshConnection( + createTarget({ host: 'cert.example.com', label: 'Cert host' }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + } + ) + + it('reports the system SSH failure when ssh2 cannot negotiate with the host', async () => { + ssh2Mock.connectBehavior = 'error' + ssh2Mock.connectErrorMessage = 'Handshake failed: no matching host key format' + spawnSystemSshCommandMock.mockImplementation(() => { + throw new Error('No system ssh binary found. Install OpenSSH to use system SSH transport.') + }) + const conn = new SshConnection( + createTarget({ host: 'cert.example.com', label: 'Cert host' }), + createCallbacks() + ) + + await expect(conn.connect()).rejects.toThrow('No system ssh binary found') + expect(conn.usesSystemSshTransport()).toBe(false) + }) + + it('keeps a host pinned in the Orca key store on ssh2 when negotiation fails', async () => { + const storeDir = mkdtempSync(join(tmpdir(), 'orca-ssh-pinned-store-')) + try { + initSshHostKeyStoreFile(join(storeDir, 'orca.json')) + await trustHostKey({ + host: 'cert.example.com', + port: 22, + keyType: 'ssh-ed25519', + key: Buffer.from( + 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', + 'base64' + ) + }) + ssh2Mock.connectBehavior = 'error' + ssh2Mock.connectErrorMessage = 'Handshake failed: no matching host key format' + const conn = new SshConnection( + createTarget({ host: 'cert.example.com', label: 'Cert host' }), + createCallbacks() + ) + + await expect(conn.connect()).rejects.toThrow('no matching host key format') + expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() + } finally { + rmSync(storeDir, { recursive: true, force: true }) + } + }) }) describe('shouldUseSystemSshTransport', () => { diff --git a/src/main/ssh/connection/ssh-connection.ts b/src/main/ssh/connection/ssh-connection.ts index d2f80faef26..1297f605894 100644 --- a/src/main/ssh/connection/ssh-connection.ts +++ b/src/main/ssh/connection/ssh-connection.ts @@ -95,6 +95,8 @@ export class SshConnection { generation: number sources: Promise } | null = null + /** Generation whose ssh2 attempt found an Orca-stored key for this endpoint. */ + private orcaStorePinnedGeneration: number | null = null private systemSshControlMasterDisabledForSession = false private systemSshGssapiOnlyForSession = false private systemSshLoopbackRoute: SystemSshLoopbackRoute | null = null @@ -510,6 +512,7 @@ export class SshConnection { connect: (config) => this.doSsh2Connect(config, connectGeneration), respawnProxy: (config, proxy) => this.respawnProxy(config, proxy, connectGeneration), killProxyIfCurrent: () => this.killProxyIfCurrent(connectGeneration), + isPinnedByOrcaHostKeyStore: () => this.orcaStorePinnedGeneration === connectGeneration, probeSystemSsh: (resolved, gssapiOnly) => this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, gssapiOnly), resetSystemTransportIfCurrent: () => this.resetSystemTransportIfCurrent(connectGeneration), @@ -742,6 +745,9 @@ export class SshConnection { this.hostKeyFingerprint = fingerprint } }) + if (verdict.pinnedByOrcaStore && isCurrentAttempt()) { + this.orcaStorePinnedGeneration = connectGeneration + } return runSsh2Handshake(config, { label: this.target.label, pendingClients: this.pendingSsh2Clients, diff --git a/src/main/ssh/connection/ssh2-credential-fallback.ts b/src/main/ssh/connection/ssh2-credential-fallback.ts index 4e154e571bb..732317a5fa7 100644 --- a/src/main/ssh/connection/ssh2-credential-fallback.ts +++ b/src/main/ssh/connection/ssh2-credential-fallback.ts @@ -9,6 +9,7 @@ import { isAgentFallbackError, isGssapiSystemSshFallbackCandidate, isPassphraseError, + isSsh2AlgorithmNegotiationError, isSystemSshFallbackError } from '../ssh-reconnect-error-classification' import { isHostKeyVerificationError } from '../ssh-host-key-decision' @@ -44,6 +45,8 @@ export type Ssh2CredentialFallbackHost = { respawnProxy: (config: ConnectConfig, proxy: EffectiveProxy) => void killProxyIfCurrent: () => void probeSystemSsh: (resolved: SshResolvedConfig | null, gssapiOnly?: boolean) => Promise + /** System OpenSSH ignores Orca's store, so a host pinned only there must stay on ssh2. */ + isPinnedByOrcaHostKeyStore: () => boolean resetSystemTransportIfCurrent: () => void requestCredential: (kind: SshCredentialKind, detail: string) => Promise } @@ -111,6 +114,19 @@ export async function recoverFailedSsh2Attempt( } } + // A pinned host that suddenly shares no algorithm could be an impostor steering Orca away from its pin. + if (isSsh2AlgorithmNegotiationError(err) && !host.isPinnedByOrcaHostKeyStore()) { + host.killProxyIfCurrent() + try { + await host.probeSystemSsh(resolved) + return + } catch (probeErr) { + host.resetSystemTransportIfCurrent() + // ssh2 can never reach this host, so the system SSH failure is the one the user can act on. + throw probeErr + } + } + let authError = err let passphrasePromptHandled = false let credentialRetryConfig = config diff --git a/src/main/ssh/connection/ssh2-host-key-verification.ts b/src/main/ssh/connection/ssh2-host-key-verification.ts index bafffa84902..ca692155776 100644 --- a/src/main/ssh/connection/ssh2-host-key-verification.ts +++ b/src/main/ssh/connection/ssh2-host-key-verification.ts @@ -76,7 +76,11 @@ type Ssh2HostKeyVerificationHost = { } /** The denial this attempt's verifier recorded; local to one attempt so a stale one cannot leak. */ -type Ssh2HostKeyVerdict = { rejection: HostKeyVerificationError | null } +type Ssh2HostKeyVerdict = { + rejection: HostKeyVerificationError | null + /** Orca's own store holds a key for this endpoint, which system OpenSSH never consults. */ + pinnedByOrcaStore: boolean +} /** Installs the host verifier and key-type order on `config` before ssh2 dials. */ export async function installSsh2HostKeyVerification( @@ -97,6 +101,11 @@ export async function installSsh2HostKeyVerification( await host.loadTrustSources(sshGSuppressedSiteConfig) const siteConfigSuppressed = siteMayRestrict const knownHostsEntries = knownHostsEvidence.entries + const storedKeyTypes = storedKeyTypesForEndpoint( + trustedHostKeys, + hostKeyLookupHost, + config.port ?? 22 + ) // If ssh2's own algorithm list is unreadable, keep its defaults rather than guessing a reorder. const serverHostKeyOrder = DEFAULT_SERVER_HOST_KEY_ALGORITHMS ? orderServerHostKeyAlgorithms( @@ -104,12 +113,15 @@ export async function installSsh2HostKeyVerification( hostKeyLookupHost, config.port ?? 22, DEFAULT_SERVER_HOST_KEY_ALGORITHMS, - storedKeyTypesForEndpoint(trustedHostKeys, hostKeyLookupHost, config.port ?? 22), + storedKeyTypes, isHostKeyAlias ) : undefined // Keep denial attempt-local and retain original ssh2 error codes so stale attempts and transient failures stay distinguishable. - const verdict: Ssh2HostKeyVerdict = { rejection: null } + const verdict: Ssh2HostKeyVerdict = { + rejection: null, + pinnedByOrcaStore: storedKeyTypes.length > 0 + } // Accepted server fingerprints isolate shared-home relay locks across hosts; preserve mixed-version format and leave hostHash unset. config.hostVerifier = createHostKeyVerifier({ diff --git a/src/main/ssh/ssh-reconnect-error-classification.ts b/src/main/ssh/ssh-reconnect-error-classification.ts index fce31189dd0..f7cf0b76950 100644 --- a/src/main/ssh/ssh-reconnect-error-classification.ts +++ b/src/main/ssh/ssh-reconnect-error-classification.ts @@ -70,6 +70,11 @@ export function isSystemSshFallbackError(err: Error): boolean { return err.message.includes('EHOSTUNREACH') || err.message.includes('ENETUNREACH') } +/** ssh2 supports fewer algorithms than OpenSSH (no host certificates, for one), so no shared algorithm is a client gap. */ +export function isSsh2AlgorithmNegotiationError(err: Error): boolean { + return err.message.startsWith('Handshake failed: no matching ') +} + // ssh2 lacks GSSAPI; use system SSH for config-enabled Kerberos only after auth-shaped failures, preserving network retry semantics. export function isGssapiSystemSshFallbackCandidate( err: Error,