fix(ssh): fall back to OpenSSH for FIDO2 keys (#11913)

Closes #11645
This commit is contained in:
Neil 2026-08-01 03:27:42 -07:00 committed by GitHub
parent 1f307afa6d
commit 33c14bc716
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 836 additions and 67 deletions

View File

@ -8,18 +8,26 @@ import { isOpenSshConfigBackedTarget } from './system-ssh-args'
// Why: ssh2 only tries keys that are explicitly provided. Users with keys in
// standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would
// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile.
// fail to authenticate. Probe the regular and FIDO2 OpenSSH default paths.
const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss']
const DEFAULT_SECURITY_KEY_NAMES = ['id_ed25519_sk', 'id_ecdsa_sk']
const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`)
const DEFAULT_IDENTITY_PATHS = [...DEFAULT_KEY_NAMES, ...DEFAULT_SECURITY_KEY_NAMES].map(
(name) => `~/.ssh/${name}`
)
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
// Why: resolved IdentityFile paths are expanded before auth resolution, so they
// won't match the ~/... form in DEFAULT_KEY_PATHS.
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_PATHS.map(resolveSshConfigHomePath)
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_IDENTITY_PATHS.map(resolveSshConfigHomePath)
export type PrivateKeyFile = { path: string; contents: Buffer }
export function listDefaultIdentityFilePaths(): string[] {
return [...DEFAULT_IDENTITY_PATHS]
}
export function findDefaultKeyFile(): PrivateKeyFile | undefined {
for (const keyPath of DEFAULT_KEY_PATHS) {
const resolved = resolveSshConfigHomePath(keyPath)
@ -97,7 +105,10 @@ function resolveExplicitPrivateKeyPaths(
return resolvedIdentities
}
function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] {
export function resolveIdentityFilePaths(
target: SshTarget,
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
): string[] {
if (isOpenSshConfigBackedTarget(target) && resolved) {
return resolved.identityFile
}
@ -138,7 +149,7 @@ export function resolvePrivateKeys(
target: SshTarget,
resolved: SshResolvedConfig | null
): PrivateKeyFile[] {
const keyPaths = resolvePrivateKeyPaths(target, resolved)
const keyPaths = resolveIdentityFilePaths(target, resolved)
if (keyPaths.length > 0 || resolved || target.identityFile) {
return readPrivateKeys(keyPaths)
}
@ -174,16 +185,6 @@ export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | un
return undefined
}
function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] {
if (isOpenSshConfigBackedTarget(target) && resolved) {
return resolved.identityFile
}
if (target.identityFile) {
return [target.identityFile]
}
return resolved?.identityFile ?? []
}
export function resolveAgentConfigValue(
agentSocket: string,
target: SshTarget,

View File

@ -313,7 +313,7 @@ describe('findDefaultKeyFile', () => {
expect(result!.contents).toEqual(Buffer.from('key-contents'))
})
it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => {
it('probes regular and FIDO2 keys in stable default order', () => {
const checkedPaths: string[] = []
mockExistsSync.mockImplementation((path: unknown) => {
checkedPaths.push(String(path))
@ -331,6 +331,34 @@ describe('findDefaultKeyFile', () => {
])
})
it('keeps a regular default ahead of a malformed FIDO2 default', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return (
path === testHomePath('.ssh', 'id_rsa') || path === testHomePath('.ssh', 'id_ed25519_sk')
)
})
mockReadFileSync.mockImplementation((path: unknown) => {
if (String(path) === testHomePath('.ssh', 'id_ed25519_sk')) {
throw new Error('malformed FIDO2 key')
}
return Buffer.from('rsa-key')
})
expect(findDefaultKeyFile()).toEqual({
path: '~/.ssh/id_rsa',
contents: Buffer.from('rsa-key')
})
})
it('leaves FIDO2 defaults out of the ssh2 private-key fallback', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === testHomePath('.ssh', 'id_ed25519_sk')
})
expect(findDefaultKeyFile()).toBeUndefined()
expect(mockReadFileSync).not.toHaveBeenCalled()
})
it('skips unreadable key files and tries next', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa')

View File

@ -16,6 +16,7 @@ let execBehavior: 'callback' | 'pending' = 'callback'
let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
let sftpBehavior: 'callback' | 'pending' = 'callback'
let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
let notifyClientCreated: (() => void) | undefined
type MockSshClient = {
setNoDelay: ReturnType<typeof vi.fn>
@ -45,6 +46,8 @@ vi.mock('ssh2', () => {
lastConnectConfig?: unknown
constructor() {
clientInstances.push(this)
notifyClientCreated?.()
notifyClientCreated = undefined
}
on(event: string, handler: (...args: unknown[]) => void) {
const handlers = eventHandlers?.get(event) ?? new Set<(...args: unknown[]) => void>()
@ -168,6 +171,10 @@ import {
} from './ssh-system-fallback'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import type { SshTarget } from '../../shared/ssh-types'
import {
createOpenSshPrivateKeyFixture,
createOpenSshPublicKeyFixture
} from './ssh-security-key-identity.test-fixture'
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
return {
@ -278,6 +285,7 @@ describe('SshConnection', () => {
pendingExecCallback = null
sftpBehavior = 'callback'
pendingSftpCallback = null
notifyClientCreated = undefined
clientInstances = []
getOrcaControlSocketPathMock.mockReset()
getOrcaControlSocketPathMock.mockReturnValue(null)
@ -462,10 +470,11 @@ describe('SshConnection', () => {
const callbacks = createCallbacks()
const conn = new SshConnection(createTarget(), callbacks)
const clientCreated = new Promise<void>((resolve) => {
notifyClientCreated = resolve
})
const connectResult = conn.connect().catch((error: Error) => error)
for (let i = 0; i < 5 && clientInstances.length === 0; i++) {
await Promise.resolve()
}
await clientCreated
expect(clientInstances).toHaveLength(1)
await conn.disconnect()
@ -485,10 +494,11 @@ describe('SshConnection', () => {
const callbacks = createCallbacks()
const conn = new SshConnection(createTarget(), callbacks)
const clientCreated = new Promise<void>((resolve) => {
notifyClientCreated = resolve
})
const connectResult = conn.connect().catch((error: Error) => error)
for (let i = 0; i < 5 && clientInstances.length === 0; i++) {
await Promise.resolve()
}
await clientCreated
expect(clientInstances).toHaveLength(1)
await conn.disconnect()
@ -1462,6 +1472,47 @@ describe('SshConnection', () => {
)
})
it('uses system SSH before ssh2 parses a security-key private key', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-'))
const keyPath = join(directory, 'id_ed25519_sk')
writeFileSync(
keyPath,
createOpenSshPrivateKeyFixture(['sk-ssh-ed25519@openssh.com'], { encrypted: true })
)
const conn = new SshConnection(createTarget({ identityFile: keyPath }), createCallbacks())
try {
await conn.connect()
expect(conn.getState().status).toBe('connected')
expect(conn.usesSystemSshTransport()).toBe(true)
expect(clientInstances).toHaveLength(0)
expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(directory, { recursive: true })
}
})
it('uses system SSH for an agent-backed security-key public identity', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-'))
const identityPath = join(directory, 'id_ed25519_sk')
writeFileSync(
`${identityPath}.pub`,
createOpenSshPublicKeyFixture('sk-ssh-ed25519@openssh.com')
)
const conn = new SshConnection(createTarget({ identityFile: identityPath }), createCallbacks())
try {
await conn.connect()
expect(conn.usesSystemSshTransport()).toBe(true)
expect(clientInstances).toHaveLength(0)
expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(directory, { recursive: true })
}
})
it('falls back to system SSH when ssh2 hits a local network policy reachability error', async () => {
connectBehavior = 'error'
connectErrorMessage = 'connect EHOSTUNREACH 192.168.0.210:22 - Local (192.168.0.2:52112)'

View File

@ -41,6 +41,10 @@ import {
type SshConnectionCallbacks
} from './ssh-connection-utils'
import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication'
import {
requiresSystemSshForSecurityKey,
shouldUseSystemSshTransport
} from './ssh-transport-selection'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import {
resolveSftpTransferPathIfMapped,
@ -636,7 +640,14 @@ export class SshConnection {
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(
() => null
)
if (shouldUseSystemSshTransport(this.target, resolved)) {
const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved)
const requiresSecurityKeyTransport = usesConfiguredSystemTransport
? false
: await requiresSystemSshForSecurityKey(this.target, resolved)
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw this.createCancelledConnectAttemptError()
}
if (usesConfiguredSystemTransport || requiresSecurityKeyTransport) {
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved)
return
}
@ -1386,24 +1397,4 @@ export class SshConnection {
}
}
export function shouldUseSystemSshTransport(
target: SshTarget,
resolved: Pick<SshResolvedConfig, 'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'> | null
): boolean {
if (isOpenSshConfigBackedTarget(target) && resolved) {
return (
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
resolved.proxyUseFdpass === true ||
resolved.proxyCommand != null ||
resolved.proxyJump != null
)
}
return (
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
target.proxyCommand != null ||
target.jumpHost != null ||
resolved?.proxyUseFdpass === true ||
resolved?.proxyCommand != null ||
resolved?.proxyJump != null
)
}
export { shouldUseSystemSshTransport } from './ssh-transport-selection'

View File

@ -0,0 +1,42 @@
function encodeUint32(value: number): Buffer {
const buffer = Buffer.alloc(4)
buffer.writeUInt32BE(value)
return buffer
}
function sshString(value: string | Buffer): Buffer {
const contents = typeof value === 'string' ? Buffer.from(value, 'ascii') : value
return Buffer.concat([encodeUint32(contents.length), contents])
}
export function createOpenSshPrivateKeyFixture(
keyTypes: string[],
options: { encrypted?: boolean; cipher?: string; privateBlock?: Buffer; authTag?: Buffer } = {}
): Buffer {
const cipher = options.cipher ?? (options.encrypted ? 'aes256-ctr' : 'none')
const encrypted = cipher !== 'none'
const publicKeys = keyTypes.map((keyType) => sshString(sshString(keyType)))
const decoded = Buffer.concat([
Buffer.from('openssh-key-v1\0', 'ascii'),
sshString(cipher),
sshString(encrypted ? 'bcrypt' : 'none'),
sshString(encrypted ? Buffer.from('fixture-kdf') : Buffer.alloc(0)),
encodeUint32(publicKeys.length),
...publicKeys,
sshString(options.privateBlock ?? Buffer.from('fixture-private-block')),
options.authTag ?? Buffer.alloc(0)
])
const encoded =
decoded
.toString('base64')
.match(/.{1,70}/g)
?.join('\n') ?? ''
return Buffer.from(
`-----BEGIN OPENSSH PRIVATE KEY-----\n${encoded}\n-----END OPENSSH PRIVATE KEY-----\n`
)
}
export function createOpenSshPublicKeyFixture(keyType: string): Buffer {
const encoded = sshString(keyType).toString('base64')
return Buffer.from(`${keyType} ${encoded} fixture-comment\n`)
}

View File

@ -0,0 +1,309 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
import {
isOpenSshSecurityKeyPrivateKey,
isOpenSshSecurityKeyPublicKey
} from './ssh-security-key-identity'
import {
createOpenSshPrivateKeyFixture,
createOpenSshPublicKeyFixture
} from './ssh-security-key-identity.test-fixture'
import { requiresSystemSshForSecurityKey } from './ssh-transport-selection'
const { findSystemSshMock } = vi.hoisted(() => ({ findSystemSshMock: vi.fn() }))
vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock }))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<{ homedir: () => string }>()
return {
...actual,
homedir: () => process.env.ORCA_TEST_SSH_HOME || actual.homedir()
}
})
const ED25519_SECURITY_KEY = 'sk-ssh-ed25519@openssh.com'
const ECDSA_SECURITY_KEY = 'sk-ecdsa-sha2-nistp256@openssh.com'
const tempDirs: string[] = []
function createTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'target-1',
label: 'Test Server',
host: 'example.com',
port: 22,
username: 'deploy',
...overrides
}
}
beforeEach(() => {
findSystemSshMock.mockReset()
findSystemSshMock.mockReturnValue('/usr/bin/ssh')
})
async function writeKey(contents: Buffer, filename = 'security key'): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-'))
tempDirs.push(directory)
const keyPath = join(directory, filename)
await writeFile(keyPath, contents)
return keyPath
}
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true })))
})
describe('isOpenSshSecurityKeyPrivateKey', () => {
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
'recognizes unencrypted %s keys',
(keyType) => {
expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(true)
}
)
it('recognizes authenticated encrypted envelopes with a trailing tag', () => {
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], {
cipher: 'aes256-gcm@openssh.com',
authTag: Buffer.alloc(16, 7)
})
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true)
})
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
'recognizes encrypted %s keys from the public section',
(keyType) => {
const key = createOpenSshPrivateKeyFixture([keyType], { encrypted: true })
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true)
}
)
it.each(['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ssh-rsa'])(
'leaves regular %s keys on ssh2',
(keyType) => {
expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(false)
}
)
it('supports CRLF armored keys', () => {
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
expect(
isOpenSshSecurityKeyPrivateKey(Buffer.from(key.toString().replaceAll('\n', '\r\n')))
).toBe(true)
})
it('recognizes OpenSSH envelopes without optional base64 padding', () => {
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], {
privateBlock: Buffer.alloc(0)
})
const unpadded = Buffer.from(key.toString().replace(/=+(?=\n-----END)/, ''))
expect(unpadded).not.toEqual(key)
expect(isOpenSshSecurityKeyPrivateKey(unpadded)).toBe(true)
})
it('does not match security-key text outside a valid public-key type', () => {
const key = createOpenSshPrivateKeyFixture(['ssh-ed25519'], {
privateBlock: Buffer.from(ED25519_SECURITY_KEY)
})
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(false)
expect(
isOpenSshSecurityKeyPrivateKey(Buffer.from(`${ED25519_SECURITY_KEY} AAAA comment`))
).toBe(false)
})
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
'validates the %s type inside an OpenSSH public key blob',
(keyType) => {
expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture(keyType))).toBe(true)
}
)
it('recognizes public keys without optional base64 padding', () => {
const key = createOpenSshPublicKeyFixture(ECDSA_SECURITY_KEY)
const unpadded = Buffer.from(key.toString().replace(/=+(?=\s)/, ''))
expect(unpadded).not.toEqual(key)
expect(isOpenSshSecurityKeyPublicKey(unpadded)).toBe(true)
})
it('rejects regular or mismatched OpenSSH public key blobs', () => {
expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture('ssh-ed25519'))).toBe(false)
expect(
isOpenSshSecurityKeyPublicKey(
Buffer.from(`${ED25519_SECURITY_KEY} ${Buffer.from('ssh-ed25519').toString('base64')}`)
)
).toBe(false)
})
it('rejects malformed and truncated OpenSSH envelopes without throwing', () => {
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
const malformedLength = Buffer.concat([
Buffer.from('openssh-key-v1\0', 'ascii'),
Buffer.from([0xff, 0xff, 0xff, 0xff])
]).toString('base64')
const malformedKey = Buffer.from(
`-----BEGIN OPENSSH PRIVATE KEY-----\n${malformedLength}\n-----END OPENSSH PRIVATE KEY-----\n`
)
expect(isOpenSshSecurityKeyPrivateKey(key.subarray(0, -20))).toBe(false)
expect(isOpenSshSecurityKeyPrivateKey(malformedKey)).toBe(false)
expect(isOpenSshSecurityKeyPrivateKey(Buffer.from('not a private key'))).toBe(false)
})
})
describe('requiresSystemSshForSecurityKey', () => {
it('uses default FIDO2 identities only when config resolution is unavailable', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-home-'))
tempDirs.push(directory)
await mkdir(join(directory, '.ssh'))
await writeFile(
join(directory, '.ssh', 'id_ed25519_sk'),
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
)
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true)
await expect(
requiresSystemSshForSecurityKey(createTarget(), { identityFile: [] })
).resolves.toBe(false)
})
it('keeps a regular default ahead of a dormant FIDO2 identity', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-regular-key-home-'))
tempDirs.push(directory)
await mkdir(join(directory, '.ssh'))
await writeFile(join(directory, '.ssh', 'id_rsa'), createOpenSshPrivateKeyFixture(['ssh-rsa']))
await writeFile(
join(directory, '.ssh', 'id_ed25519_sk'),
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
)
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false)
})
it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-no-system-ssh-home-'))
tempDirs.push(directory)
await mkdir(join(directory, '.ssh'))
await writeFile(
join(directory, '.ssh', 'id_ed25519_sk'),
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
)
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
findSystemSshMock.mockReturnValue(null)
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false)
})
it('ignores an orphan regular sidecar before a valid default FIDO2 identity', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-orphan-sidecar-home-'))
tempDirs.push(directory)
await mkdir(join(directory, '.ssh'))
await writeFile(
join(directory, '.ssh', 'id_ed25519.pub'),
createOpenSshPublicKeyFixture('ssh-ed25519')
)
await writeFile(
join(directory, '.ssh', 'id_ed25519_sk'),
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
)
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true)
})
it('detects a manual target identity path with spaces', async () => {
const keyPath = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]))
await expect(
requiresSystemSshForSecurityKey(createTarget({ identityFile: keyPath }), null)
).resolves.toBe(true)
})
it('checks every fresh resolved identity for config-backed targets', async () => {
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular')
const securityKey = await writeKey(
createOpenSshPrivateKeyFixture([ECDSA_SECURITY_KEY], { encrypted: true }),
'security'
)
const target = createTarget({
source: 'ssh-config',
configHost: 'workbox',
identityFile: '/stale/security-key'
})
await expect(
requiresSystemSshForSecurityKey(target, { identityFile: [regularKey, securityKey] })
).resolves.toBe(true)
})
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
'detects an agent-backed %s identity from its public sidecar',
async (keyType) => {
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-agent-'))
tempDirs.push(directory)
const identityPath = join(directory, 'agent-key')
await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(keyType))
await expect(
requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null)
).resolves.toBe(true)
}
)
it('ignores a stale FIDO2 sidecar beside a regular private identity', async () => {
const identityPath = await writeKey(
createOpenSshPrivateKeyFixture(['ssh-ed25519']),
'regular-with-stale-sidecar'
)
await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(ED25519_SECURITY_KEY))
await expect(
requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null)
).resolves.toBe(false)
})
it('ignores stale imported identity paths when fresh config has regular keys', async () => {
const staleKey = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), 'stale')
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular')
const target = createTarget({
source: 'ssh-config',
configHost: 'workbox',
identityFile: staleKey
})
await expect(
requiresSystemSshForSecurityKey(target, { identityFile: [regularKey] })
).resolves.toBe(false)
})
it('keeps a manual target identity authoritative over resolved defaults', async () => {
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'manual')
const securityKey = await writeKey(
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]),
'resolved'
)
await expect(
requiresSystemSshForSecurityKey(
createTarget({ source: 'manual', identityFile: regularKey }),
{ identityFile: [securityKey] }
)
).resolves.toBe(false)
})
it('degrades to ssh2 when identity files are missing or malformed', async () => {
const malformedKey = await writeKey(Buffer.from('not a key'), 'malformed')
await expect(
requiresSystemSshForSecurityKey(createTarget({ identityFile: malformedKey }), null)
).resolves.toBe(false)
await expect(
requiresSystemSshForSecurityKey(
createTarget({ identityFile: join(tmpdir(), 'missing-security-key') }),
null
)
).resolves.toBe(false)
})
})

View File

@ -0,0 +1,107 @@
const OPENSSH_PRIVATE_KEY_HEADER = '-----BEGIN OPENSSH PRIVATE KEY-----'
const OPENSSH_PRIVATE_KEY_FOOTER = '-----END OPENSSH PRIVATE KEY-----'
const OPENSSH_KEY_MAGIC = Buffer.from('openssh-key-v1\0', 'ascii')
export const MAX_SSH_IDENTITY_FILE_BYTES = 1024 * 1024
const MAX_PUBLIC_KEYS = 64
const SECURITY_KEY_TYPES = new Set([
'sk-ssh-ed25519@openssh.com',
'sk-ecdsa-sha2-nistp256@openssh.com'
])
type SshString = { value: Buffer; nextOffset: number }
function decodeBase64(value: string): Buffer | null {
if (!value || value.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
return null
}
const unpadded = value.replace(/=+$/, '')
const decoded = Buffer.from(unpadded, 'base64')
return decoded.toString('base64').replace(/=+$/, '') === unpadded ? decoded : null
}
function readSshString(buffer: Buffer, offset: number): SshString | null {
if (offset < 0 || offset + 4 > buffer.length) {
return null
}
const length = buffer.readUInt32BE(offset)
const start = offset + 4
const end = start + length
if (end < start || end > buffer.length) {
return null
}
return { value: buffer.subarray(start, end), nextOffset: end }
}
function decodeOpenSshPrivateKey(contents: Buffer): Buffer | null {
if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) {
return null
}
const lines = contents.toString('ascii').trim().split(/\r?\n/)
if (lines[0] !== OPENSSH_PRIVATE_KEY_HEADER || lines.at(-1) !== OPENSSH_PRIVATE_KEY_FOOTER) {
return null
}
const encoded = lines.slice(1, -1).join('')
const decoded = decodeBase64(encoded)
if (!decoded) {
return null
}
return decoded.subarray(0, OPENSSH_KEY_MAGIC.length).equals(OPENSSH_KEY_MAGIC) ? decoded : null
}
export function isOpenSshSecurityKeyPrivateKey(contents: Buffer): boolean {
const decoded = decodeOpenSshPrivateKey(contents)
if (!decoded) {
return false
}
let offset = OPENSSH_KEY_MAGIC.length
for (let field = 0; field < 3; field++) {
const value = readSshString(decoded, offset)
if (!value) {
return false
}
offset = value.nextOffset
}
if (offset + 4 > decoded.length) {
return false
}
const keyCount = decoded.readUInt32BE(offset)
offset += 4
if (keyCount === 0 || keyCount > MAX_PUBLIC_KEYS) {
return false
}
let hasSecurityKey = false
for (let keyIndex = 0; keyIndex < keyCount; keyIndex++) {
const publicKey = readSshString(decoded, offset)
if (!publicKey) {
return false
}
offset = publicKey.nextOffset
const keyType = readSshString(publicKey.value, 0)
if (!keyType) {
return false
}
hasSecurityKey ||= SECURITY_KEY_TYPES.has(keyType.value.toString('ascii'))
}
const privateBlock = readSshString(decoded, offset)
return privateBlock !== null && hasSecurityKey
}
export function isOpenSshSecurityKeyPublicKey(contents: Buffer): boolean {
if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) {
return false
}
const [declaredType, encoded] = contents.toString('ascii').trim().split(/\s+/, 3)
if (!declaredType || !encoded || !SECURITY_KEY_TYPES.has(declaredType)) {
return false
}
const decoded = decodeBase64(encoded)
if (!decoded) {
return false
}
const keyType = readSshString(decoded, 0)
return keyType?.value.toString('ascii') === declaredType
}

View File

@ -24,7 +24,6 @@ vi.mock('child_process', () => ({
import {
buildSshArgs,
findSystemSsh,
downloadFileViaSystemSsh,
spawnSystemSsh,
spawnSystemSshCommand,
@ -152,22 +151,6 @@ function createMockChildProcess(): EventEmitter & {
return child
}
describe('findSystemSsh', () => {
beforeEach(() => {
existsSyncMock.mockReset()
})
it('returns the first existing ssh path', () => {
mockSystemSshExists()
expect(findSystemSsh()).toBe(SYSTEM_SSH_PATH)
})
it('returns null when no ssh binary is found', () => {
existsSyncMock.mockReturnValue(false)
expect(findSystemSsh()).toBeNull()
})
})
describe('spawnSystemSsh', () => {
let mockProc: {
stdin: {
@ -827,6 +810,7 @@ describe('spawnSystemSsh', () => {
it('throws when no system ssh is found', () => {
existsSyncMock.mockReturnValue(false)
vi.stubEnv('PATH', '')
expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found')
})

View File

@ -0,0 +1,118 @@
import { constants } from 'node:fs'
import { open, stat } from 'node:fs/promises'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
import { listDefaultIdentityFilePaths, resolveIdentityFilePaths } from './ssh-auth-resolution'
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
import {
MAX_SSH_IDENTITY_FILE_BYTES,
isOpenSshSecurityKeyPrivateKey,
isOpenSshSecurityKeyPublicKey
} from './ssh-security-key-identity'
import { isOpenSshConfigBackedTarget } from './system-ssh-args'
import { findSystemSsh } from './system-ssh-binary'
type TransportResolvedConfig = Pick<
SshResolvedConfig,
'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'
>
const READ_CHUNK_BYTES = 64 * 1024
const READ_OPEN_FLAGS =
constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
type IdentityInspection = { privateIdentityExists: boolean; requiresSystemSsh: boolean }
async function readBoundedKeyFile(path: string): Promise<Buffer | null> {
let handle: Awaited<ReturnType<typeof open>> | undefined
try {
const pathStats = await stat(path)
if (!pathStats.isFile() || pathStats.size > MAX_SSH_IDENTITY_FILE_BYTES) {
return null
}
handle = await open(path, READ_OPEN_FLAGS)
const stats = await handle.stat()
if (!stats.isFile() || stats.size > MAX_SSH_IDENTITY_FILE_BYTES) {
return null
}
const chunks: Buffer[] = []
let offset = 0
while (offset <= MAX_SSH_IDENTITY_FILE_BYTES) {
const buffer = Buffer.alloc(
Math.min(READ_CHUNK_BYTES, MAX_SSH_IDENTITY_FILE_BYTES + 1 - offset)
)
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset)
if (bytesRead === 0) {
break
}
offset += bytesRead
if (offset > MAX_SSH_IDENTITY_FILE_BYTES) {
return null
}
chunks.push(buffer.subarray(0, bytesRead))
}
return Buffer.concat(chunks, offset)
} catch {
return null
} finally {
await handle?.close().catch(() => undefined)
}
}
async function inspectIdentityPath(keyPath: string): Promise<IdentityInspection> {
const resolvedPath = resolveSshConfigHomePath(keyPath)
const identity = await readBoundedKeyFile(resolvedPath)
if (identity !== null) {
return {
privateIdentityExists: true,
requiresSystemSsh:
isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity)
}
}
const publicIdentity = await readBoundedKeyFile(`${resolvedPath}.pub`)
return {
privateIdentityExists: false,
requiresSystemSsh: publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity)
}
}
export function shouldUseSystemSshTransport(
target: SshTarget,
resolved: TransportResolvedConfig | null
): boolean {
if (isOpenSshConfigBackedTarget(target) && resolved) {
return (
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
resolved.proxyUseFdpass === true ||
resolved.proxyCommand != null ||
resolved.proxyJump != null
)
}
return (
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
target.proxyCommand != null ||
target.jumpHost != null ||
resolved?.proxyUseFdpass === true ||
resolved?.proxyCommand != null ||
resolved?.proxyJump != null
)
}
export async function requiresSystemSshForSecurityKey(
target: SshTarget,
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
): Promise<boolean> {
const configuredPaths = resolveIdentityFilePaths(target, resolved)
const usesDefaultPaths = configuredPaths.length === 0 && !resolved && !target.identityFile
const identityPaths = usesDefaultPaths ? listDefaultIdentityFilePaths() : configuredPaths
for (const keyPath of identityPaths) {
const inspection = await inspectIdentityPath(keyPath)
if (inspection.requiresSystemSsh) {
return !usesDefaultPaths || findSystemSsh() !== null
}
if (usesDefaultPaths && inspection.privateIdentityExists) {
return false
}
}
return false
}

View File

@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { accessSyncMock, existsSyncMock, statSyncMock } = vi.hoisted(() => ({
accessSyncMock: vi.fn(),
existsSyncMock: vi.fn(),
statSyncMock: vi.fn()
}))
vi.mock('node:fs', () => ({
accessSync: accessSyncMock,
constants: { O_RDONLY: 0, O_NONBLOCK: 4, X_OK: 1 },
existsSync: existsSyncMock,
statSync: statSyncMock
}))
import { findSystemSsh } from './system-ssh-binary'
describe('findSystemSsh', () => {
beforeEach(() => {
accessSyncMock.mockReset()
existsSyncMock.mockReset()
statSyncMock.mockReset()
statSyncMock.mockImplementation(() => {
throw new Error('missing')
})
vi.unstubAllEnvs()
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns the first existing fixed ssh path', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
existsSyncMock.mockImplementation((path: string) => path === '/usr/bin/ssh')
try {
expect(findSystemSsh()).toBe('/usr/bin/ssh')
} finally {
platformSpy.mockRestore()
}
})
it('returns null when no ssh binary is found', () => {
existsSyncMock.mockReturnValue(false)
expect(findSystemSsh()).toBeNull()
})
it('finds a PATH-installed ssh.exe on Windows', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('PATH', 'C:\\Git\\usr\\bin;C:\\Tools')
existsSyncMock.mockReturnValue(false)
statSyncMock.mockImplementation((path: string) => {
if (path === 'C:\\Git\\usr\\bin\\ssh.exe') {
return { isFile: () => true }
}
throw new Error('missing')
})
try {
expect(findSystemSsh()).toBe('C:\\Git\\usr\\bin\\ssh.exe')
} finally {
platformSpy.mockRestore()
}
})
it('finds an executable PATH-installed ssh on POSIX', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
vi.stubEnv('PATH', '/nix/store/ssh/bin:/opt/tools')
existsSyncMock.mockReturnValue(false)
statSyncMock.mockImplementation((path: string) => {
if (path === '/nix/store/ssh/bin/ssh') {
return { isFile: () => true }
}
throw new Error('missing')
})
try {
expect(findSystemSsh()).toBe('/nix/store/ssh/bin/ssh')
expect(accessSyncMock).toHaveBeenCalledWith('/nix/store/ssh/bin/ssh', expect.any(Number))
} finally {
platformSpy.mockRestore()
}
})
it('resolves Windows OpenSSH from the runtime system root', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('SystemRoot', 'D:\\Windows')
existsSyncMock.mockImplementation(
(path: string) => path === 'D:\\Windows\\System32\\OpenSSH\\ssh.exe'
)
try {
expect(findSystemSsh()).toBe('D:\\Windows\\System32\\OpenSSH\\ssh.exe')
} finally {
platformSpy.mockRestore()
}
})
it('keeps an explicit system ssh override authoritative', () => {
vi.stubEnv('ORCA_SYSTEM_SSH_PATH', 'C:\\Custom\\ssh.exe')
expect(findSystemSsh()).toBe('C:\\Custom\\ssh.exe')
expect(existsSyncMock).not.toHaveBeenCalled()
})
})

View File

@ -1,9 +1,41 @@
import { existsSync } from 'node:fs'
import { accessSync, constants, existsSync, statSync } from 'node:fs'
import { posix, win32 } from 'node:path'
const SYSTEM_SSH_PATHS =
process.platform === 'win32'
? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe']
: ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh']
function systemSshPaths(platform: NodeJS.Platform): string[] {
if (platform !== 'win32') {
return ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh']
}
const systemRoot = process.env.SystemRoot || process.env.WINDIR
return systemRoot ? [win32.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe')] : []
}
function findSshOnPath(platform: NodeJS.Platform): string | null {
const pathValue = process.env.PATH
if (!pathValue) {
return null
}
const pathApi = platform === 'win32' ? win32 : posix
const executable = platform === 'win32' ? 'ssh.exe' : 'ssh'
for (const entry of pathValue.split(pathApi.delimiter)) {
const directory = entry.trim().replace(/^"|"$/g, '')
if (!directory) {
continue
}
const candidate = pathApi.join(directory, executable)
try {
if (!statSync(candidate).isFile()) {
continue
}
if (platform !== 'win32') {
accessSync(candidate, constants.X_OK)
}
return candidate
} catch {
continue
}
}
return null
}
/**
* Find the system ssh binary path. Returns null if not found.
@ -12,10 +44,10 @@ export function findSystemSsh(): string | null {
if (process.env.ORCA_SYSTEM_SSH_PATH) {
return process.env.ORCA_SYSTEM_SSH_PATH
}
for (const candidate of SYSTEM_SSH_PATHS) {
for (const candidate of systemSshPaths(process.platform)) {
if (existsSync(candidate)) {
return candidate
}
}
return null
return findSshOnPath(process.platform)
}