mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user