From d7b3f454de6c44e5beba3cc574f77aaba1b8451e Mon Sep 17 00:00:00 2001 From: jhkim0911 Date: Tue, 14 Jul 2026 11:06:19 -0500 Subject: [PATCH 1/2] feat(ssh): support keyboard-interactive authentication (MFA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers that require a keyboard-interactive round (RFC 4256) after password auth — typical for HPC clusters and other hosts behind Duo/OTP/push MFA — failed with "All configured authentication methods failed" right after the password was entered, because ssh2 never advertised the keyboard-interactive method and had no handler for its prompts. The same hosts connect fine with OpenSSH and VS Code Remote SSH. - Enable tryKeyboard on every ssh2 connect config and answer keyboard-interactive prompt rounds through the existing credential request flow, so the MFA challenge reaches the user instead of failing the connection. - Route password-looking prompts (masked, mention "password", not one-time/OTP) through the password credential kind and its in-memory cache: servers that collect the login password via keyboard-interactive keep silent reconnects, and a rejected cached password falls through to a fresh prompt instead of looping. All other prompts (push approval, passcodes, option menus) always reach the user, with the server's instructions shown and the RFC 4256 echo flag deciding whether the input is masked. - Stop the 30s ready timeout once the server starts prompting: it budgets the network handshake, not a human answering an MFA push. The server's LoginGraceTime and the 120s credential dialog timeout still bound the wait. - Treat a cancelled keyboard-interactive prompt as an explicit user decision: fail the round with no answers and skip the password re-prompt fallback. Fixes #8622 --- src/main/ipc/ssh-passphrase.ts | 5 +- src/main/ipc/ssh.ts | 4 +- src/main/ssh/ssh-connection-utils.test.ts | 5 + src/main/ssh/ssh-connection-utils.ts | 13 +- src/main/ssh/ssh-connection.test.ts | 132 +++++++++++- src/main/ssh/ssh-connection.ts | 66 +++++- src/main/ssh/ssh-keyboard-interactive.test.ts | 192 ++++++++++++++++++ src/main/ssh/ssh-keyboard-interactive.ts | 94 +++++++++ src/preload/api-types.ts | 3 +- src/preload/index.ts | 6 +- .../settings/SshPassphraseDialog.tsx | 91 ++++++--- src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- src/renderer/src/store/slices/ssh.ts | 5 +- 17 files changed, 593 insertions(+), 48 deletions(-) create mode 100644 src/main/ssh/ssh-keyboard-interactive.test.ts create mode 100644 src/main/ssh/ssh-keyboard-interactive.ts diff --git a/src/main/ipc/ssh-passphrase.ts b/src/main/ipc/ssh-passphrase.ts index e1e21045373..bb9cc1e1c49 100644 --- a/src/main/ipc/ssh-passphrase.ts +++ b/src/main/ipc/ssh-passphrase.ts @@ -19,7 +19,8 @@ export function requestCredential( getMainWindow: () => BrowserWindow | null, targetId: string, kind: SshCredentialKind, - detail: string + detail: string, + echo?: boolean ): Promise { const requestId = randomUUID() return new Promise((resolve) => { @@ -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) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index affb1aba004..d1976751f39 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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)) { diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 0c578cff7d2..5cedb44d73d 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -404,6 +404,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') diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index cc8269c1b48..ba610ec629a 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -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 } @@ -135,7 +138,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 diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 005d43c9ca8..960758149d8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -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' @@ -69,6 +71,9 @@ vi.mock('ssh2', () => { emitSshEvent('ready') return } + if (next === 'silent') { + return + } if (connectBehavior === 'error') { const err = new Error(connectErrorMessage) as NodeJS.ErrnoException if (connectErrorCode) { @@ -657,6 +662,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 in /bin/sh so non-POSIX login shells do not parse relay snippets', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index af74bdc712a..56d55c7ea15 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -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, @@ -37,6 +37,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' @@ -81,6 +82,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) { @@ -516,6 +518,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( @@ -630,6 +633,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) { @@ -963,6 +975,57 @@ 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. + clearTimeout((client as unknown as { _readyTimeout?: NodeJS.Timeout })._readyTimeout) + 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 ?? []) + } + }, + () => { + if (!settled) { + finish([]) + } + } + ) } const swallowLateStartupError = (): void => { // Why: ssh2 can emit another socket error while a failed or cancelled @@ -1026,6 +1089,7 @@ export class SshConnection { client.on('ready', onReady) client.on('error', onStartupError) + client.on('keyboard-interactive', onKeyboardInteractive) client.connect(config) }) } diff --git a/src/main/ssh/ssh-keyboard-interactive.test.ts b/src/main/ssh/ssh-keyboard-interactive.test.ts new file mode 100644 index 00000000000..6f16c74249f --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest' +import { + collectKeyboardInteractiveResponses, + formatKeyboardInteractivePromptDetail, + isKeyboardInteractivePasswordPrompt, + type KeyboardInteractiveSession +} from './ssh-keyboard-interactive' + +function createSession( + overrides: Partial = {} +): KeyboardInteractiveSession & { cancelled: { value: boolean } } { + const cancelled = { value: false } + return { + targetId: 'target-1', + hostDetail: 'example.com', + requestCredential: vi.fn(async () => 'answer'), + getCachedPassword: () => null, + setCachedPassword: vi.fn(), + markCancelled: () => { + cancelled.value = true + }, + isCancelled: () => cancelled.value, + state: { passwordAutoAnswered: false }, + cancelled, + ...overrides + } +} + +describe('isKeyboardInteractivePasswordPrompt', () => { + it('matches masked password prompts', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Password: ', echo: false })).toBe(true) + expect(isKeyboardInteractivePasswordPrompt({ prompt: "user@host's PASSWORD:" })).toBe(true) + }) + + it('rejects echoed prompts even when they mention a password', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Password: ', echo: true })).toBe(false) + }) + + it('rejects one-time password and OTP prompts', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'One-time password:', echo: false })).toBe( + false + ) + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'OTP password code:', echo: false })).toBe( + false + ) + }) + + it('rejects verification prompts that never mention a password', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Passcode:', echo: false })).toBe(false) + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Duo push sent', echo: false })).toBe( + false + ) + }) +}) + +describe('formatKeyboardInteractivePromptDetail', () => { + it('joins instructions and prompt on separate lines', () => { + expect(formatKeyboardInteractivePromptDetail('Pick an option:', 'Passcode: ')).toBe( + 'Pick an option:\nPasscode:' + ) + }) + + it('drops the missing half', () => { + expect(formatKeyboardInteractivePromptDetail('', 'Passcode: ')).toBe('Passcode:') + expect(formatKeyboardInteractivePromptDetail('Approve the push. ', '')).toBe( + 'Approve the push.' + ) + }) +}) + +describe('collectKeyboardInteractiveResponses', () => { + it('forwards verification prompts with instructions and the echo flag', async () => { + const requestCredential = vi.fn(async () => '1') + const session = createSession({ requestCredential }) + + const responses = await collectKeyboardInteractiveResponses(session, 'Choose an option:', [ + { prompt: 'Passcode or option (1-2):', echo: true } + ]) + + expect(responses).toEqual(['1']) + expect(requestCredential).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'Choose an option:\nPasscode or option (1-2):', + true + ) + }) + + it('answers a password prompt from the cache without prompting', async () => { + const requestCredential = vi.fn(async () => 'unused') + const session = createSession({ + requestCredential, + getCachedPassword: () => 'password-123' + }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['password-123']) + expect(requestCredential).not.toHaveBeenCalled() + expect(session.state.passwordAutoAnswered).toBe(true) + }) + + it('re-prompts when the server rejects the auto-answered cached password', async () => { + const requestCredential = vi.fn(async () => 'corrected-password') + const setCachedPassword = vi.fn() + const session = createSession({ + requestCredential, + getCachedPassword: () => 'stale-password', + setCachedPassword, + state: { passwordAutoAnswered: true } + }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['corrected-password']) + expect(requestCredential).toHaveBeenCalledWith('target-1', 'password', 'example.com') + expect(setCachedPassword).toHaveBeenCalledWith('corrected-password') + }) + + it('collects and caches the password when nothing is cached yet', async () => { + const requestCredential = vi.fn(async () => 'password-123') + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false }, + { prompt: 'Duo push approval', echo: false } + ]) + + expect(responses).toEqual(['password-123', 'password-123']) + expect(requestCredential).toHaveBeenNthCalledWith(1, 'target-1', 'password', 'example.com') + expect(requestCredential).toHaveBeenNthCalledWith( + 2, + 'target-1', + 'keyboard-interactive', + 'Duo push approval', + false + ) + expect(setCachedPassword).toHaveBeenCalledWith('password-123') + }) + + it('returns null and marks the session cancelled when the user dismisses a prompt', async () => { + const session = createSession({ requestCredential: vi.fn(async () => null) }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Duo passcode:', echo: false } + ]) + + expect(responses).toBeNull() + expect(session.isCancelled()).toBe(true) + }) + + it('short-circuits every later round once cancelled', async () => { + const requestCredential = vi.fn(async () => 'answer') + const session = createSession({ requestCredential }) + session.markCancelled() + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Duo passcode:', echo: false } + ]) + + expect(responses).toBeNull() + expect(requestCredential).not.toHaveBeenCalled() + }) + + it('returns null when no credential prompter is available', async () => { + const session = createSession({ requestCredential: undefined }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toBeNull() + }) + + it('accepts an empty response without caching it as a password', async () => { + const requestCredential = vi.fn(async () => '') + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['']) + expect(setCachedPassword).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-keyboard-interactive.ts b/src/main/ssh/ssh-keyboard-interactive.ts new file mode 100644 index 00000000000..7186fbdac4e --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive.ts @@ -0,0 +1,94 @@ +import type { Prompt } from 'ssh2' +import type { SshConnectionCallbacks } from './ssh-connection-utils' + +// Why: servers commonly reuse keyboard-interactive (RFC 4256) to collect the +// login password itself before issuing MFA challenges. Password-looking +// prompts route through the password credential flow (and its cache) so +// reconnects stay silent; every other prompt needs a fresh human answer. +const PASSWORD_PROMPT = /password/i +// Why: a one-time code answered with the cached login password would burn MFA +// attempts (and can lock the account) on every reconnect. +const ONE_TIME_PROMPT = /one.?time|otp/i + +export function isKeyboardInteractivePasswordPrompt(prompt: Prompt): boolean { + return ( + prompt.echo !== true && + PASSWORD_PROMPT.test(prompt.prompt) && + !ONE_TIME_PROMPT.test(prompt.prompt) + ) +} + +export function formatKeyboardInteractivePromptDetail( + instructions: string, + promptText: string +): string { + const trimmedInstructions = instructions.trim() + const trimmedPrompt = promptText.trim() + if (!trimmedInstructions || !trimmedPrompt) { + return trimmedInstructions || trimmedPrompt + } + return `${trimmedInstructions}\n${trimmedPrompt}` +} + +export type KeyboardInteractiveSession = { + targetId: string + hostDetail: string + requestCredential: SshConnectionCallbacks['onCredentialRequest'] + getCachedPassword: () => string | null + setCachedPassword: (value: string) => void + markCancelled: () => void + isCancelled: () => boolean + // Why: one cached-password auto-answer per connection attempt — a second + // password prompt in the same attempt means the server rejected the cached + // value, so the user must be asked again instead of looping a bad password. + state: { passwordAutoAnswered: boolean } +} + +// Answers one keyboard-interactive round. Returns null when the user +// cancelled (or no prompter is available); callers respond with no answers so +// the server fails the round and the regular auth-error flow takes over. +export async function collectKeyboardInteractiveResponses( + session: KeyboardInteractiveSession, + instructions: string, + prompts: Prompt[] +): Promise { + if (session.isCancelled()) { + return null + } + const responses: string[] = [] + for (const prompt of prompts) { + const value = isKeyboardInteractivePasswordPrompt(prompt) + ? await answerPasswordPrompt(session) + : await session.requestCredential?.( + session.targetId, + 'keyboard-interactive', + formatKeyboardInteractivePromptDetail(instructions, prompt.prompt), + prompt.echo + ) + if (value == null) { + session.markCancelled() + return null + } + responses.push(value) + } + return responses +} + +async function answerPasswordPrompt( + session: KeyboardInteractiveSession +): Promise { + const cached = session.getCachedPassword() + if (cached != null && !session.state.passwordAutoAnswered) { + session.state.passwordAutoAnswered = true + return cached + } + const value = await session.requestCredential?.(session.targetId, 'password', session.hostDetail) + if (value == null) { + return value + } + session.state.passwordAutoAnswered = true + if (value) { + session.setCachedPassword(value) + } + return value +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 80e284bf31f..940ce98c541 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3072,8 +3072,9 @@ export type PreloadApi = { callback: (data: { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean }) => void ) => () => void onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index bfea3e27a5d..040cda8df48 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4173,8 +4173,9 @@ const api = { callback: (data: { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean }) => void ): (() => void) => { const listener = ( @@ -4182,8 +4183,9 @@ const api = { data: { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean } ) => callback(data) ipcRenderer.on('ssh:credential-request', listener) diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index 3f25f5d91e2..92fccd497dc 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -61,7 +61,10 @@ export function SshPassphraseDialog(): React.JSX.Element | null { ) const handleSubmit = useCallback(async () => { - if (!request || !value) { + // Why: keyboard-interactive servers may accept an empty response (e.g. + // plain Enter to trigger a default MFA push); passwords and passphrases + // are never empty. + if (!request || (!value && request.kind !== 'keyboard-interactive')) { return } setSubmitting(true) @@ -107,21 +110,38 @@ export function SshPassphraseDialog(): React.JSX.Element | null { const label = targetLabels.get(request.targetId) ?? request.targetId const isPassword = request.kind === 'password' + const isKeyboardInteractive = request.kind === 'keyboard-interactive' return ( !isOpen && void handleCancel()}> - {isPassword - ? translate('auto.components.settings.SshPassphraseDialog.106bd57f4a', 'SSH Password') - : translate( - 'auto.components.settings.SshPassphraseDialog.1f3dde805d', - 'SSH Key Passphrase' - )} + {isKeyboardInteractive + ? translate( + 'auto.components.settings.SshPassphraseDialog.6d2b7b77b7', + 'SSH Verification' + ) + : isPassword + ? translate( + 'auto.components.settings.SshPassphraseDialog.106bd57f4a', + 'SSH Password' + ) + : translate( + 'auto.components.settings.SshPassphraseDialog.1f3dde805d', + 'SSH Key Passphrase' + )} - {isPassword ? ( + {isKeyboardInteractive ? ( + <> + {translate( + 'auto.components.settings.SshPassphraseDialog.baf23a0fcf', + 'Respond to the verification prompt from' + )} + {label} + + ) : isPassword ? ( <> {translate( 'auto.components.settings.SshPassphraseDialog.dbf9b6f2d0', @@ -143,24 +163,26 @@ export function SshPassphraseDialog(): React.JSX.Element | null {
setValue(e.target.value)} onKeyDown={(e) => { @@ -170,15 +192,20 @@ export function SshPassphraseDialog(): React.JSX.Element | null { } }} placeholder={ - isPassword + isKeyboardInteractive ? translate( - 'auto.components.settings.SshPassphraseDialog.abaa0dc653', - 'Enter password' - ) - : translate( - 'auto.components.settings.SshPassphraseDialog.c3ce71aad6', - 'Enter passphrase' + 'auto.components.settings.SshPassphraseDialog.7effa48279', + 'Enter response' ) + : isPassword + ? translate( + 'auto.components.settings.SshPassphraseDialog.abaa0dc653', + 'Enter password' + ) + : translate( + 'auto.components.settings.SshPassphraseDialog.c3ce71aad6', + 'Enter passphrase' + ) } className="h-8 text-sm" disabled={submitting} @@ -193,8 +220,12 @@ export function SshPassphraseDialog(): React.JSX.Element | null { > {translate('auto.components.settings.SshPassphraseDialog.d5a234456f', 'Cancel')} - diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4fc41a01115..f45376b1f89 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6592,7 +6592,10 @@ "8a349e3fac": "Passphrase for {{value0}}", "cab3d5f5a5": "Password for {{value0}}", "1f3dde805d": "SSH Key Passphrase", - "106bd57f4a": "SSH Password" + "106bd57f4a": "SSH Password", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "a883f5a00f": "terminal timeout: {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 788d29209e5..4a2fbfccc50 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6555,7 +6555,10 @@ "8a349e3fac": "Frase de contraseña para {{value0}}", "cab3d5f5a5": "Contraseña para {{value0}}", "1f3dde805d": "Frase de contraseña de clave SSH", - "106bd57f4a": "Contraseña SSH" + "106bd57f4a": "Contraseña SSH", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "Conectar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b92cf73d46e..0e7b846ebcb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6577,7 +6577,10 @@ "8a349e3fac": "{{value0}} のパスフレーズ", "cab3d5f5a5": "{{value0}} のパスワード", "1f3dde805d": "SSH キーのパスフレーズ", - "106bd57f4a": "SSH パスワード" + "106bd57f4a": "SSH パスワード", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "接続", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 05eacf25060..ca3dfba5c71 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6540,7 +6540,10 @@ "8a349e3fac": "{{value0}}의 암호", "cab3d5f5a5": "{{value0}}의 비밀번호", "1f3dde805d": "SSH 키 암호", - "106bd57f4a": "SSH 비밀번호" + "106bd57f4a": "SSH 비밀번호", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "연결", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2519b1856e5..42257f833f3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6540,7 +6540,10 @@ "8a349e3fac": "{{value0}} 的密码", "cab3d5f5a5": "{{value0}} 的密码", "1f3dde805d": "SSH 密钥密码", - "106bd57f4a": "SSH 密码" + "106bd57f4a": "SSH 密码", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "连接", diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 6b80e054353..74ce3e7c08e 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -24,8 +24,11 @@ export type RemoteWorkspaceSyncStatus = { export type SshCredentialRequest = { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + /** RFC 4256 echo flag for keyboard-interactive prompts: when true the + * server allows the typed response to be shown. */ + echo?: boolean } export type SshSlice = { From 87e7a596d0ef6dc08f225288d25ffc12dab286a1 Mon Sep 17 00:00:00 2001 From: jhkim0911 Date: Tue, 14 Jul 2026 11:34:22 -0500 Subject: [PATCH 2/2] fix(ssh): surface keyboard-interactive failures instead of swallowing them Review follow-up for keyboard-interactive support: - Log the rejection reason when answering a keyboard-interactive round fails, so a broken credential prompter is distinguishable from an expected user cancellation. - Guard the ssh2 internal _readyTimeout access and warn when the handle is missing, so a future ssh2 internals change can't silently revert MFA prompts to the 30s handshake timeout. --- src/main/ssh/ssh-connection.test.ts | 4 ++++ src/main/ssh/ssh-connection.ts | 21 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 960758149d8..076674c65b3 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -42,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() { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 56d55c7ea15..48b9311da87 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -994,7 +994,17 @@ export class SshConnection { // 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. - clearTimeout((client as unknown as { _readyTimeout?: NodeJS.Timeout })._readyTimeout) + 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, @@ -1020,7 +1030,14 @@ export class SshConnection { 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([]) }