fix(ssh): retry over system ssh when ssh2 shares no algorithm with the host (#33680)

* fix(ssh): retry over system ssh when ssh2 shares no algorithm with the host

ssh2 supports fewer algorithms than OpenSSH. A host that offers only
certificate host keys (or only kex/cipher choices ssh2 lacks) fails the
handshake with "Handshake failed: no matching ...", even though the
system ssh binary connects fine. Treat any such negotiation failure as a
client capability gap: probe system ssh once, and if that also fails,
report its error, since ssh2 can never reach the host.

Fixes #33582

Co-authored-by: Kyou12138 <kyou12138@gmail.com>

* fix(ssh): keep hosts pinned in Orca's key store on ssh2 after a negotiation failure

System OpenSSH never reads Orca's own host key store, so an on-path
impostor could advertise algorithms ssh2 lacks to steer a pinned host
onto system ssh and around its pin. Only fall back when Orca has no
stored key for the endpoint.

Co-authored-by: Kyou12138 <kyou12138@gmail.com>

* fix(ssh): only the current attempt records an Orca host key pin

A superseded attempt that finished its trust read late could overwrite
the current attempt's pin marker, letting a negotiation failure on a
pinned host fall back to system ssh.

---------

Co-authored-by: Kyou12138 <kyou12138@gmail.com>
This commit is contained in:
Neil
2026-10-25 17:19:09 -07:00
committed by GitHub
co-authored by Kyou12138
parent bfc633af8f
commit 1b58be9ddc
7 changed files with 170 additions and 7 deletions
@@ -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()
})
@@ -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']
@@ -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', () => {
@@ -95,6 +95,8 @@ export class SshConnection {
generation: number
sources: Promise<HostKeyTrustSources>
} | 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,
@@ -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<void>
/** 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<string | null | undefined>
}
@@ -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
@@ -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({
@@ -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,