Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/main/ipc/ssh-passphrase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function requestCredential(
getMainWindow: () => BrowserWindow | null,
targetId: string,
kind: SshCredentialKind,
detail: string
detail: string,
echo?: boolean
): Promise<string | null> {
const requestId = randomUUID()
return new Promise((resolve) => {
Expand All @@ -39,7 +40,7 @@ export function requestCredential(

const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail })
win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail, echo })
} else {
pendingRequests.delete(requestId)
clearTimeout(timer)
Expand Down
4 changes: 2 additions & 2 deletions src/main/ipc/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,9 +510,9 @@ function registerPowerMonitorReconnect(): void {

function createSshConnectionCallbacks(): SshConnectionCallbacks {
return {
onCredentialRequest: (targetId, kind, detail) => {
onCredentialRequest: (targetId, kind, detail, echo) => {
credentialRequestedForTarget.add(targetId)
return requestCredential(getCurrentMainWindow, targetId, kind, detail)
return requestCredential(getCurrentMainWindow, targetId, kind, detail, echo)
},
onStateChange: (targetId: string, state: SshConnectionState) => {
if (testingTargets.has(targetId)) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/ssh/ssh-connection-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,11 @@ describe('buildConnectConfig', () => {
expect(config.keepaliveInterval).toBe(15_000)
})

it('enables keyboard-interactive auth so MFA challenges can be answered', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.tryKeyboard).toBe(true)
})

it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
Expand Down
13 changes: 10 additions & 3 deletions src/main/ssh/ssh-connection-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ import {

export { findDefaultKeyFile, resolveAgentSocket } from './ssh-auth-resolution'

export type SshCredentialKind = 'passphrase' | 'password'
export type SshCredentialKind = 'passphrase' | 'password' | 'keyboard-interactive'

export type SshConnectionCallbacks = {
onStateChange: (targetId: string, state: SshConnectionState) => void
// Why: echo carries the server's RFC 4256 echo flag for keyboard-interactive
// prompts so the UI knows whether the typed response may be shown.
onCredentialRequest?: (
targetId: string,
kind: SshCredentialKind,
detail: string
detail: string,
echo?: boolean
) => Promise<string | null>
}

Expand Down Expand Up @@ -187,7 +190,11 @@ export function buildConnectConfig(
port: effectivePort,
username: effectiveUser,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 15_000
keepaliveInterval: 15_000,
// Why: lets servers deliver keyboard-interactive challenges (RFC 4256,
// commonly MFA after a partial password success). SshConnection answers
// them through the credential prompt callback.
tryKeyboard: true
}

const shouldIncludeAgent = options.includeAgent ?? true
Expand Down
136 changes: 135 additions & 1 deletion src/main/ssh/ssh-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ let connectBehavior: 'ready' | 'error' = 'ready'
let connectErrorMessage = ''
let connectErrorCode = ''
let destroyErrorMessage = ''
let connectSequence: ('ready' | Error)[] = []
// Why: 'silent' leaves the connect attempt pending so a test can drive auth
// events (e.g. keyboard-interactive prompts) through emitSshEvent itself.
let connectSequence: ('ready' | 'silent' | Error)[] = []
let execBehavior: 'callback' | 'pending' = 'callback'
let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
let sftpBehavior: 'callback' | 'pending' = 'callback'
Expand Down Expand Up @@ -40,6 +42,10 @@ vi.mock('ssh2', () => {
// to decide which log line to emit. A real Socket instance lets the test
// exercise the "enabled" branch instead of the "skipped (proxy socket)" branch.
_sock: Socket | undefined = new Socket()
// Why: production code clears ssh2's internal handshake timer when a
// keyboard-interactive prompt arrives (and warns when the field is
// missing); a dummy timer keeps that path exercised without the warning.
_readyTimeout: NodeJS.Timeout = setTimeout(() => {}, 0)
lastExecCommand?: string
lastConnectConfig?: unknown
constructor() {
Expand Down Expand Up @@ -69,6 +75,9 @@ vi.mock('ssh2', () => {
emitSshEvent('ready')
return
}
if (next === 'silent') {
return
}
if (connectBehavior === 'error') {
const err = new Error(connectErrorMessage) as NodeJS.ErrnoException
if (connectErrorCode) {
Expand Down Expand Up @@ -657,6 +666,131 @@ describe('SshConnection', () => {
}
})

it('completes password + keyboard-interactive MFA auth and forwards the prompt', async () => {
vi.stubEnv('SSH_AUTH_SOCK', '')
connectSequence = [new Error('All configured authentication methods failed'), 'silent']
const onCredentialRequest = vi.fn(async (_targetId: string, kind: string) =>
kind === 'password' ? 'password-123' : '1'
)
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))

const connectPromise = conn.connect()
await vi.waitFor(() => {
expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0)
expect(clientInstances).toHaveLength(2)
})
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'',
'Choose a verification method:',
'',
[{ prompt: 'Passcode or option (1-2):', echo: true }],
finish
)
await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['1']))
emitSshEvent('ready')
await connectPromise

expect(conn.getState().status).toBe('connected')
const retryConfig = clientInstances[1].lastConnectConfig as {
password?: string
tryKeyboard?: boolean
}
expect(retryConfig.tryKeyboard).toBe(true)
expect(retryConfig.password).toBe('password-123')
expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', 'example.com')
expect(onCredentialRequest).toHaveBeenCalledWith(
'target-1',
'keyboard-interactive',
'Choose a verification method:\nPasscode or option (1-2):',
true
)
})

it('auto-answers a keyboard-interactive password prompt with the cached password', async () => {
vi.stubEnv('SSH_AUTH_SOCK', '')
connectSequence = [new Error('All configured authentication methods failed'), 'silent']
const onCredentialRequest = vi.fn(async () => 'password-123')
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))

const connectPromise = conn.connect()
await vi.waitFor(() => {
expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0)
expect(clientInstances).toHaveLength(2)
})
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'',
'',
'',
[{ prompt: 'Password: ', echo: false }],
finish
)
await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123']))
emitSshEvent('ready')
await connectPromise

expect(conn.getState().status).toBe('connected')
expect(onCredentialRequest).toHaveBeenCalledTimes(1)
})

it('caches a password collected through a keyboard-interactive prompt', async () => {
vi.stubEnv('SSH_AUTH_SOCK', '')
connectSequence = ['silent']
const onCredentialRequest = vi.fn(async () => 'password-123')
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))

const connectPromise = conn.connect()
await vi.waitFor(() =>
expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0)
)
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'',
'',
'',
[{ prompt: 'Password: ', echo: false }],
finish
)
await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123']))
emitSshEvent('ready')
await connectPromise

expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', 'example.com')
expect(conn.hasCachedCredential()).toBe(true)
})

it('does not fall back to the password prompt after a cancelled keyboard-interactive prompt', async () => {
vi.stubEnv('SSH_AUTH_SOCK', '')
connectSequence = ['silent']
const onCredentialRequest = vi.fn(async () => null)
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))

const connectPromise = conn.connect()
connectPromise.catch(() => {})
await vi.waitFor(() =>
expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0)
)
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'',
'',
'',
[{ prompt: 'Duo passcode:', echo: false }],
finish
)
await vi.waitFor(() => expect(finish).toHaveBeenCalledWith([]))
emitSshEvent('error', new Error('All configured authentication methods failed'))

await expect(connectPromise).rejects.toThrow('All configured authentication methods failed')
expect(onCredentialRequest).toHaveBeenCalledTimes(1)
expect(conn.getState().status).toBe('auth-failed')
})

it('wraps exec commands as a single line that csh/tcsh login shells cannot break', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
Expand Down
83 changes: 82 additions & 1 deletion src/main/ssh/ssh-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as net from 'node:net'
import { Client as SshClient } from 'ssh2'
import type { ChildProcess } from 'node:child_process'
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2'
import type { ClientChannel, ConnectConfig, Prompt, SFTPWrapper } from 'ssh2'
import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types'
import {
getOrcaControlSocketPath,
Expand Down Expand Up @@ -38,6 +38,7 @@ import {
type SshExecOptions,
type SshConnectionCallbacks
} from './ssh-connection-utils'
import { collectKeyboardInteractiveResponses } from './ssh-keyboard-interactive'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import type { FileUploadSession } from '../providers/types'
import { isSshSessionLimitError } from './ssh-session-limit-error'
Expand Down Expand Up @@ -83,6 +84,7 @@ export class SshConnection {
private disposed = false
private cachedPassphrase: string | null = null
private cachedPassword: string | null = null
private keyboardInteractiveCancelled = false
private connectGeneration = 0

constructor(target: SshTarget, callbacks: SshConnectionCallbacks) {
Expand Down Expand Up @@ -524,6 +526,7 @@ export class SshConnection {
this.setState('connecting')
this.proxyProcess?.kill()
this.proxyProcess = null
this.keyboardInteractiveCancelled = false
const connectGeneration = ++this.connectGeneration

const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(
Expand Down Expand Up @@ -688,6 +691,15 @@ export class SshConnection {
throw authError
}

// Why: a cancelled keyboard-interactive prompt is an explicit user
// decision; falling through to the password prompt would ask again for
// the same login.
if (this.keyboardInteractiveCancelled) {
this.proxyProcess?.kill()
this.proxyProcess = null
throw authError
}

// Why: prompt for passphrase on encrypted-key error, then retry with
// a fresh proxy socket (ssh2 may have destroyed the original).
if (isPassphraseError(authError) && !this.cachedPassphrase && !passphrasePromptHandled) {
Expand Down Expand Up @@ -1027,6 +1039,74 @@ export class SshConnection {
const cleanupStartupListeners = (): void => {
client.off('ready', onReady)
client.off('error', onStartupError)
client.off('keyboard-interactive', onKeyboardInteractive)
}

// Why: MFA servers deliver their challenges via keyboard-interactive
// (RFC 4256), typically after password auth partially succeeds. Without
// a handler ssh2 skips the method and the whole auth fails with "All
// configured authentication methods failed".
const kiState = { passwordAutoAnswered: false }
const onKeyboardInteractive = (
_name: string,
instructions: string,
_lang: string,
prompts: Prompt[],
finish: (responses: string[]) => void
): void => {
// Why: readyTimeout budgets the network handshake, but these prompts
// wait on a human (push approval, OTP entry). A prompt proves the
// server is alive; from here the server's LoginGraceTime and the
// credential dialog timeout bound the wait.
const readyTimeout = (client as unknown as { _readyTimeout?: NodeJS.Timeout })._readyTimeout
if (readyTimeout !== undefined) {
clearTimeout(readyTimeout)
} else {
// Why: this reaches into ssh2 internals — if the field moves, the
// handshake timeout silently cuts MFA prompts short again. Make
// that breakage visible.
console.warn(
`[ssh] Could not clear the handshake timeout for ${this.target.label}; keyboard-interactive prompts may be cut short`
)
}
collectKeyboardInteractiveResponses(
{
targetId: this.target.id,
hostDetail: config.host || this.target.label,
requestCredential: this.callbacks.onCredentialRequest,
getCachedPassword: () => this.cachedPassword,
setCachedPassword: (value) => {
this.cachedPassword = value
},
markCancelled: () => {
this.keyboardInteractiveCancelled = true
},
isCancelled: () => this.keyboardInteractiveCancelled,
state: kiState
},
instructions,
prompts
).then(
(responses) => {
// Why: a late answer must not write to a client that already
// timed out or was torn down by disconnect/reconnect.
if (!settled) {
finish(responses ?? [])
}
},
(err) => {
// Why: distinguishes a real failure (e.g. a broken credential
// prompter) from an expected user cancellation in the logs.
console.warn(
`[ssh] keyboard-interactive handling failed for ${this.target.label}: ${
err instanceof Error ? err.message : String(err)
}`
)
if (!settled) {
finish([])
}
}
)
}
const swallowLateStartupError = (): void => {
// Why: ssh2 can emit another socket error while a failed or cancelled
Expand Down Expand Up @@ -1090,6 +1170,7 @@ export class SshConnection {

client.on('ready', onReady)
client.on('error', onStartupError)
client.on('keyboard-interactive', onKeyboardInteractive)
client.connect(config)
})
}
Expand Down
Loading