Files
orca/src/main/ssh/ssh-auth-resolution.ts
T
NeilandSangrak Choi b7eaef8148 fix(ssh): try the waiting ~/.ssh key before showing a password dialog (#29397)
* fix(ssh): hold the password challenge until the deferred key has been tried

When an agent is configured, the first attempt carries only unencrypted
explicit keys and leaves default-name (~/.ssh/id_*) and encrypted keys to the
no-agent retry. keyboard-interactive still sat at the end of that first
attempt's ladder, so a host that collects its password through
keyboard-interactive showed the Password: dialog before the deferred key that
authenticates had been tried. Since #15588 that prompt goes through the
password dialog and caches the answer, and cancelling it marks the attempt
cancelled, which also skips the key retry, so the connection fails instead of
falling back to the key.

An attempt that carries no key but defers some now skips keyboard-interactive
when the host lists it as a first-factor method next to publickey. When the
host does not list it, the challenge can only be a second factor after the
agent key partially succeeded, so it stays in the ladder. When the host does
not list publickey, the deferred key cannot help, so it stays too. Attempts
that carry a key keep the challenge: SshConnection never retries them with the
deferred keys, and the password retry reuses their config.

The deferral applies only when every deferred key parses as a private key that
needs no passphrase. With an encrypted one the retry would ask for its
passphrase instead, so skipping the challenge would only swap one dialog for
another, and cancelling the passphrase dialog fails the connection rather than
reaching the password flow. A file that is no usable private key (corrupted, a
public key under a private-key name, an unsupported type) makes ssh2's
connect() throw before the retry sends any auth request, which is not an auth
error, so the connection would fail without ever offering the challenge. Those
targets keep the origin/main ladder.

The cost: when the host rejects the deferred key, its password challenge now
arrives on the key retry, one failed handshake (plus a respawned ProxyCommand,
if configured) later than on origin/main.

Constraint: ssh2 1.17 hands an agent key's USERAUTH_FAILURE to the next agent key before recording partialSuccess, so after an agent partial success the auth handler sees the pre-agent method list with partialSuccess=false
Constraint: ssh2 parses config.privateKey before any agent auth can run, so default-name and encrypted keys stay deferred to the no-agent retry
Constraint: SshConnection runs the no-agent key retry only when the agent-first attempt carried no key
Rejected: drop keyboard-interactive from the deferring attempt's whole initial ladder | ssh2 hides an agent partial success, so publickey,keyboard-interactive hosts lose their second factor
Rejected: defer whenever any key is deferred | a mixed-key attempt gets no key retry, so a keyboard-interactive password host would never see the challenge
Rejected: defer for encrypted keys too | the Password dialog becomes a passphrase dialog, and cancelling it fails a connection that origin/main completes
Rejected: check the deferred keys only for encryption | a garbage or public key file aborts the retry with a non-auth error, failing a connection that origin/main completes
Rejected: run the no-agent retry even when no key is waiting on disk | adds the same extra handshake for agent users who have no key to try in between
Directive: keep deferKeyboardInteractive in step with the key-retry guard in SshConnection.attemptConnect
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Tested: pnpm test src/main/ssh; real-socket fixtures for a first-factor challenge on a publickey,keyboard-interactive host, an encrypted default key, and an agent partial success followed by keyboard-interactive; unit tests for a garbage or public key file under a default key name; mutation checks (no deferral, no key check, encryption-only check, publickey guard removed, whole-ladder withholding, any-key-deferred condition) each fail a new test
Not-tested: the reworked revision against a live host
Related: #15588

* fix(ssh): keep an agent key's second-factor challenge when the host first offered both methods

* fix(ssh): defer the password challenge only for a single waiting key

* fix(ssh): read the deferred key only after the agent attempt reaches the challenge

---------

Co-authored-by: Sangrak Choi <kars@kargn.as>
2026-10-15 23:17:15 -07:00

200 lines
6.4 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import { utils, type BaseAgent, type ParsedKey } from 'ssh2'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
import { createIdentityFilteredAgent } from './ssh-agent-identity-filter'
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
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. 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_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)
try {
if (!existsSync(resolved)) {
continue
}
const contents = readFileSync(resolved)
return { path: keyPath, contents }
} catch {
continue
}
}
return undefined
}
function expandIdentityAgentEnv(value: string): string | undefined {
if (value === 'SSH_AUTH_SOCK') {
return process.env.SSH_AUTH_SOCK || undefined
}
let missingEnv = false
const expanded = value.replace(/\$(\w+)|\$\{([^}]+)\}/g, (_match, bare, braced) => {
const envName = String(bare || braced)
const envValue = process.env[envName]
if (envValue === undefined) {
missingEnv = true
return ''
}
return envValue
})
return missingEnv ? undefined : expanded
}
function resolveDefaultAgentSocket(): string | undefined {
return (
process.env.SSH_AUTH_SOCK ||
(process.platform === 'win32' ? WINDOWS_OPENSSH_AGENT_PIPE : undefined)
)
}
export function resolveAgentSocket(
target: Pick<SshTarget, 'identityAgent' | 'configHost' | 'source' | 'host'>,
resolved: Pick<SshResolvedConfig, 'identityAgent'> | null
): string | undefined {
// Why: imported config-host targets may contain raw OpenSSH tokens like %d.
// ssh -G resolves those tokens, so its value must win when available.
const configuredIdentityAgent = isOpenSshConfigBackedTarget(target)
? (resolved?.identityAgent ?? target.identityAgent)
: (target.identityAgent ?? resolved?.identityAgent)
if (configuredIdentityAgent != null) {
const trimmed = configuredIdentityAgent.trim()
if (!trimmed || trimmed.toLowerCase() === 'none') {
return undefined
}
return expandIdentityAgentEnv(resolveSshConfigHomePath(trimmed))
}
return resolveDefaultAgentSocket()
}
function resolveExplicitPrivateKeyPaths(
target: SshTarget,
resolved: SshResolvedConfig | null
): string[] {
const resolvedIdentities = (resolved?.identityFile ?? []).filter(
(identityFile) => !EXPANDED_DEFAULT_KEY_PATHS.includes(identityFile)
)
if (isOpenSshConfigBackedTarget(target) && resolved) {
return resolvedIdentities
}
if (target.identityFile) {
return [target.identityFile]
}
return resolvedIdentities
}
export function resolveIdentityFilePaths(
target: SshTarget,
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
): string[] {
if (isOpenSshConfigBackedTarget(target) && resolved) {
return resolved.identityFile
}
if (target.identityFile) {
return [target.identityFile]
}
return resolved?.identityFile ?? []
}
function readPrivateKey(keyPath: string): PrivateKeyFile | undefined {
try {
const resolvedPath = resolveSshConfigHomePath(keyPath)
return { path: keyPath, contents: readFileSync(resolvedPath) }
} catch {
return undefined
}
}
function readPrivateKeys(keyPaths: string[]): PrivateKeyFile[] {
const keys: PrivateKeyFile[] = []
for (const keyPath of keyPaths) {
const key = readPrivateKey(keyPath)
if (key) {
keys.push(key)
}
}
return keys
}
function resolveExplicitPrivateKeys(
target: SshTarget,
resolved: SshResolvedConfig | null
): PrivateKeyFile[] {
return readPrivateKeys(resolveExplicitPrivateKeyPaths(target, resolved))
}
export function resolvePrivateKeys(
target: SshTarget,
resolved: SshResolvedConfig | null
): PrivateKeyFile[] {
const keyPaths = resolveIdentityFilePaths(target, resolved)
if (keyPaths.length > 0 || resolved || target.identityFile) {
return readPrivateKeys(keyPaths)
}
const defaultKey = findDefaultKeyFile()
return defaultKey ? [defaultKey] : []
}
export function isUnencryptedPrivateKey(contents: Buffer): boolean {
const parsed = utils.parseKey(contents) as ParsedKey | ParsedKey[] | Error
if (parsed instanceof Error) {
return false
}
const keys = Array.isArray(parsed) ? parsed : [parsed]
return keys.some((key) => key && typeof key.isPrivateKey === 'function' && key.isPrivateKey())
}
export function resolveUnencryptedExplicitPrivateKeys(
target: SshTarget,
resolved: SshResolvedConfig | null
): PrivateKeyFile[] {
return resolveExplicitPrivateKeys(target, resolved).filter((key) =>
isUnencryptedPrivateKey(key.contents)
)
}
export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | undefined {
for (const key of keys) {
const parsed = utils.parseKey(key.contents) as ParsedKey | ParsedKey[] | Error
if (parsed instanceof Error && /passphrase|encrypted key|bad decrypt/i.test(parsed.message)) {
return key.path
}
}
return undefined
}
export function resolveAgentConfigValue(
agentSocket: string,
target: SshTarget,
resolved: SshResolvedConfig | null
): BaseAgent | string | undefined {
const identitiesOnly = resolved?.identitiesOnly ?? target.identitiesOnly ?? false
if (!identitiesOnly) {
return agentSocket
}
return createIdentityFilteredAgent(agentSocket, resolveIdentityFilePaths(target, resolved))
}